sheetdiff 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.
- sheetdiff/__init__.py +8 -0
- sheetdiff/cli.py +42 -0
- sheetdiff/config.py +127 -0
- sheetdiff/core.py +356 -0
- sheetdiff/storage.py +201 -0
- sheetdiff/templates/diff.html +511 -0
- sheetdiff/templates/index.html +325 -0
- sheetdiff/webapp.py +176 -0
- sheetdiff-0.1.0.dist-info/METADATA +190 -0
- sheetdiff-0.1.0.dist-info/RECORD +14 -0
- sheetdiff-0.1.0.dist-info/WHEEL +5 -0
- sheetdiff-0.1.0.dist-info/entry_points.txt +2 -0
- sheetdiff-0.1.0.dist-info/licenses/LICENSE +21 -0
- sheetdiff-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>xl-diff</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--bg-a: #eef2e9; --bg-b: #e2eadd;
|
|
13
|
+
--card: #fffefb; --card-2: #f4f7f0;
|
|
14
|
+
--border: #cbdac5; --border-soft: #e0e8da;
|
|
15
|
+
--text: #16241a; --muted: #5c6d5e;
|
|
16
|
+
--emerald: #1f6f4d; --emerald-2: #123f2b; --emerald-soft: #3f9c6e;
|
|
17
|
+
--brass: #a97a2c; --brass-soft: #c99c4a;
|
|
18
|
+
--ink: #fdfaf1;
|
|
19
|
+
--del: #7a2e2e; --del-bg: #f4e4e0;
|
|
20
|
+
--add: #1f5c45; --add-bg: #e2ede4;
|
|
21
|
+
--radius: 16px; --radius-sm: 10px;
|
|
22
|
+
--shadow: 0 20px 60px -20px rgba(10, 26, 17, 0.35), 0 2px 8px rgba(10,26,17,.08);
|
|
23
|
+
}
|
|
24
|
+
:root[data-theme="dark"] {
|
|
25
|
+
--bg-a: #0b120d; --bg-b: #070a07;
|
|
26
|
+
--card: #101a12; --card-2: #142017;
|
|
27
|
+
--border: #223a2a; --border-soft: #1a2a1e;
|
|
28
|
+
--text: #e9f1e6; --muted: #8fa38f;
|
|
29
|
+
--emerald: #37a877; --emerald-2: #1f6f4d; --emerald-soft: #63cf9c;
|
|
30
|
+
--brass: #d8b463; --brass-soft: #eccb84;
|
|
31
|
+
--ink: #0c1710;
|
|
32
|
+
--del: #e2938a; --del-bg: #2c1c1a;
|
|
33
|
+
--add: #8fcdad; --add-bg: #17251d;
|
|
34
|
+
--shadow: 0 20px 60px -20px rgba(0,0,0,.65), 0 2px 8px rgba(0,0,0,.3);
|
|
35
|
+
}
|
|
36
|
+
@media (prefers-color-scheme: dark) {
|
|
37
|
+
:root:not([data-theme="light"]) {
|
|
38
|
+
--bg-a: #0b120d; --bg-b: #070a07;
|
|
39
|
+
--card: #101a12; --card-2: #142017;
|
|
40
|
+
--border: #223a2a; --border-soft: #1a2a1e;
|
|
41
|
+
--text: #e9f1e6; --muted: #8fa38f;
|
|
42
|
+
--emerald: #37a877; --emerald-2: #1f6f4d; --emerald-soft: #63cf9c;
|
|
43
|
+
--brass: #d8b463; --brass-soft: #eccb84;
|
|
44
|
+
--ink: #0c1710;
|
|
45
|
+
--del: #e2938a; --del-bg: #2c1c1a;
|
|
46
|
+
--add: #8fcdad; --add-bg: #17251d;
|
|
47
|
+
--shadow: 0 20px 60px -20px rgba(0,0,0,.65), 0 2px 8px rgba(0,0,0,.3);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
* { box-sizing: border-box; }
|
|
51
|
+
body {
|
|
52
|
+
font-family: 'Inter', -apple-system, Segoe UI, sans-serif;
|
|
53
|
+
background:
|
|
54
|
+
radial-gradient(1200px 600px at 15% -10%, color-mix(in srgb, var(--emerald) 12%, transparent), transparent),
|
|
55
|
+
radial-gradient(900px 500px at 100% 10%, color-mix(in srgb, var(--brass) 10%, transparent), transparent),
|
|
56
|
+
linear-gradient(180deg, var(--bg-a), var(--bg-b));
|
|
57
|
+
background-attachment: fixed;
|
|
58
|
+
color: var(--text); margin: 0; min-height: 100vh;
|
|
59
|
+
display: flex; align-items: center; justify-content: center; padding: 32px 20px;
|
|
60
|
+
}
|
|
61
|
+
.theme-toggle {
|
|
62
|
+
position: fixed; top: 18px; right: 18px; background: var(--card);
|
|
63
|
+
border: 1px solid var(--border); border-radius: 999px; padding: 8px 14px;
|
|
64
|
+
cursor: pointer; font-size: 13px; color: var(--text); box-shadow: 0 4px 14px rgba(0,0,0,.08);
|
|
65
|
+
transition: transform .15s ease, border-color .15s ease;
|
|
66
|
+
}
|
|
67
|
+
.theme-toggle:hover { transform: translateY(-1px); border-color: var(--brass); }
|
|
68
|
+
|
|
69
|
+
.card {
|
|
70
|
+
background: var(--card);
|
|
71
|
+
border: 1px solid var(--border);
|
|
72
|
+
border-radius: var(--radius);
|
|
73
|
+
box-shadow: var(--shadow);
|
|
74
|
+
padding: 44px 44px 40px; width: 100%; max-width: 560px;
|
|
75
|
+
position: relative;
|
|
76
|
+
overflow: hidden;
|
|
77
|
+
}
|
|
78
|
+
.card::before {
|
|
79
|
+
content: ""; position: absolute; top: 0; left: 0; right: 0; height: 4px;
|
|
80
|
+
background: linear-gradient(90deg, var(--brass), var(--emerald-soft), var(--emerald));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.logo { display: flex; align-items: center; gap: 14px; margin-bottom: 6px; }
|
|
84
|
+
.logo-badge {
|
|
85
|
+
width: 46px; height: 46px; border-radius: 10px;
|
|
86
|
+
background: linear-gradient(155deg, var(--emerald-2), var(--emerald) 55%, var(--emerald-2));
|
|
87
|
+
display: flex; align-items: center; justify-content: center;
|
|
88
|
+
color: var(--brass-soft); font-weight: 700; font-size: 18px;
|
|
89
|
+
font-family: 'Cormorant Garamond', serif;
|
|
90
|
+
box-shadow: inset 0 0 0 1px rgba(255,255,255,.12), 0 6px 16px -6px rgba(18,63,43,.6);
|
|
91
|
+
}
|
|
92
|
+
h1 {
|
|
93
|
+
font-family: 'Cormorant Garamond', serif; font-weight: 700;
|
|
94
|
+
font-size: 32px; margin: 0; letter-spacing: .01em;
|
|
95
|
+
}
|
|
96
|
+
.sub {
|
|
97
|
+
color: var(--muted); font-size: 14.5px; margin: 10px 0 30px;
|
|
98
|
+
font-family: 'Cormorant Garamond', serif; font-size: 18px; font-weight: 500;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.section-label {
|
|
102
|
+
display: flex; align-items: center; gap: 10px; margin: 0 0 14px;
|
|
103
|
+
font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); font-weight: 600;
|
|
104
|
+
}
|
|
105
|
+
.section-label::after { content: ""; flex: 1; height: 1px; background: var(--border-soft); }
|
|
106
|
+
|
|
107
|
+
.drop { display: flex; flex-direction: column; gap: 12px; }
|
|
108
|
+
|
|
109
|
+
.file-row {
|
|
110
|
+
display: flex; align-items: center; gap: 10px;
|
|
111
|
+
background: var(--card-2);
|
|
112
|
+
border: 1px solid var(--border-soft);
|
|
113
|
+
border-radius: var(--radius-sm);
|
|
114
|
+
padding: 10px 12px;
|
|
115
|
+
transition: border-color .15s ease, box-shadow .15s ease;
|
|
116
|
+
}
|
|
117
|
+
.file-row.dragover { border-color: var(--emerald); box-shadow: 0 0 0 3px color-mix(in srgb, var(--emerald) 18%, transparent); }
|
|
118
|
+
|
|
119
|
+
.badge {
|
|
120
|
+
font-family: 'Cormorant Garamond', serif;
|
|
121
|
+
font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
|
|
122
|
+
padding: 4px 10px; border-radius: 999px; color: #fff; min-width: 48px; text-align: center; flex-shrink: 0;
|
|
123
|
+
}
|
|
124
|
+
.file-row:nth-child(1) .badge { background: linear-gradient(135deg, var(--del), #9a4a44); }
|
|
125
|
+
.file-row:not(:first-child) .badge { background: linear-gradient(135deg, var(--add), #2c7a5c); }
|
|
126
|
+
|
|
127
|
+
.pick-btn {
|
|
128
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
129
|
+
background: transparent; border: 1px dashed var(--border);
|
|
130
|
+
color: var(--text); font-size: 12.5px; font-weight: 600;
|
|
131
|
+
padding: 8px 12px; border-radius: 8px; cursor: pointer; white-space: nowrap;
|
|
132
|
+
transition: border-color .15s ease, color .15s ease;
|
|
133
|
+
}
|
|
134
|
+
.pick-btn:hover { border-color: var(--brass); color: var(--brass); }
|
|
135
|
+
.file-row input[type=file] { display: none; }
|
|
136
|
+
|
|
137
|
+
.file-name {
|
|
138
|
+
flex: 1; min-width: 0; font-size: 13px; color: var(--muted);
|
|
139
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
140
|
+
}
|
|
141
|
+
.file-name.has-file { color: var(--text); font-weight: 500; }
|
|
142
|
+
|
|
143
|
+
.row-or { color: var(--muted); font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em; flex-shrink: 0; }
|
|
144
|
+
input[type=text].remote-url {
|
|
145
|
+
flex: 1; min-width: 100px; font-size: 12.5px; padding: 8px 10px;
|
|
146
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
147
|
+
border: 1px solid var(--border-soft); border-radius: 8px; background: var(--card); color: var(--text);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.remove-file {
|
|
151
|
+
border: none; background: transparent; color: var(--muted); cursor: pointer;
|
|
152
|
+
font-size: 18px; line-height: 1; padding: 4px 6px; border-radius: 6px; flex-shrink: 0;
|
|
153
|
+
transition: color .15s ease, background .15s ease;
|
|
154
|
+
}
|
|
155
|
+
.remove-file:hover { color: var(--del); background: var(--del-bg); }
|
|
156
|
+
|
|
157
|
+
.add-file {
|
|
158
|
+
margin-top: 2px; background: transparent; border: 1px dashed var(--border); color: var(--emerald);
|
|
159
|
+
padding: 10px; border-radius: var(--radius-sm); font-size: 13px; font-weight: 600; cursor: pointer; width: 100%;
|
|
160
|
+
transition: border-color .15s ease, background .15s ease;
|
|
161
|
+
}
|
|
162
|
+
.add-file:hover { border-color: var(--emerald); background: color-mix(in srgb, var(--emerald) 6%, transparent); }
|
|
163
|
+
|
|
164
|
+
label { display: block; font-weight: 600; font-size: 13px; }
|
|
165
|
+
input[type=text]:not(.remote-url) {
|
|
166
|
+
display: block; margin-top: 8px; width: 100%; padding: 10px 13px;
|
|
167
|
+
border: 1px solid var(--border-soft); border-radius: 8px; font-size: 13.5px;
|
|
168
|
+
background: var(--card-2); color: var(--text);
|
|
169
|
+
}
|
|
170
|
+
input:focus { outline: 2px solid var(--emerald); outline-offset: 1px; }
|
|
171
|
+
.field { margin-top: 22px; }
|
|
172
|
+
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; line-height: 1.5; }
|
|
173
|
+
|
|
174
|
+
.check { display: flex; align-items: center; gap: 9px; margin-top: 22px; font-weight: 500; font-size: 13.5px; cursor: pointer; }
|
|
175
|
+
.check input { width: auto; margin: 0; accent-color: var(--emerald); }
|
|
176
|
+
|
|
177
|
+
button[type=submit] {
|
|
178
|
+
margin-top: 30px; width: 100%;
|
|
179
|
+
background: linear-gradient(135deg, var(--emerald-2), var(--emerald) 60%, var(--emerald-soft));
|
|
180
|
+
color: var(--ink); border: 0; padding: 14px 16px; border-radius: 10px;
|
|
181
|
+
font-size: 14.5px; font-weight: 700; letter-spacing: .01em; cursor: pointer;
|
|
182
|
+
box-shadow: 0 10px 24px -8px rgba(31,111,77,.5);
|
|
183
|
+
transition: transform .15s ease, box-shadow .15s ease;
|
|
184
|
+
}
|
|
185
|
+
button[type=submit]:hover { transform: translateY(-1px); box-shadow: 0 14px 30px -8px rgba(169,129,47,.65); }
|
|
186
|
+
button[type=submit]:active { transform: translateY(0); }
|
|
187
|
+
</style>
|
|
188
|
+
</head>
|
|
189
|
+
<body>
|
|
190
|
+
<button class="theme-toggle" onclick="toggleTheme()" id="themeBtn">☀️ / 🌙</button>
|
|
191
|
+
<div class="card">
|
|
192
|
+
<div class="logo">
|
|
193
|
+
<div class="logo-badge">X</div>
|
|
194
|
+
<h1>xl-diff</h1>
|
|
195
|
+
</div>
|
|
196
|
+
<p class="sub">Compare two or more spreadsheets with a refined, line-by-line diff.</p>
|
|
197
|
+
|
|
198
|
+
<form action="/diff" method="post" enctype="multipart/form-data">
|
|
199
|
+
<div class="section-label">Files</div>
|
|
200
|
+
<div class="drop" id="fileList">
|
|
201
|
+
<div class="file-row">
|
|
202
|
+
<span class="badge">Base</span>
|
|
203
|
+
<button type="button" class="pick-btn" onclick="this.nextElementSibling.click()">Choose file</button>
|
|
204
|
+
<input type="file" name="files" accept=".xlsx,.xls,.xlsm,.csv" onchange="updateFileName(this)">
|
|
205
|
+
<span class="file-name">No file selected</span>
|
|
206
|
+
{% if remote_enabled %}<span class="row-or">or</span>
|
|
207
|
+
<input type="text" class="remote-url" name="remote_refs" placeholder="s3://bucket/file.xlsx">{% endif %}
|
|
208
|
+
</div>
|
|
209
|
+
<div class="file-row">
|
|
210
|
+
<span class="badge">v2</span>
|
|
211
|
+
<button type="button" class="pick-btn" onclick="this.nextElementSibling.click()">Choose file</button>
|
|
212
|
+
<input type="file" name="files" accept=".xlsx,.xls,.xlsm,.csv" onchange="updateFileName(this)">
|
|
213
|
+
<span class="file-name">No file selected</span>
|
|
214
|
+
{% if remote_enabled %}<span class="row-or">or</span>
|
|
215
|
+
<input type="text" class="remote-url" name="remote_refs" placeholder="s3://bucket/file.xlsx">{% endif %}
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
<button type="button" class="add-file" onclick="addFile()">+ Add another file</button>
|
|
219
|
+
|
|
220
|
+
{% if remote_enabled %}
|
|
221
|
+
<div class="hint">
|
|
222
|
+
Each row accepts either an uploaded file or a remote URI ({{ allowed_schemes | join(', ') }}).
|
|
223
|
+
Uploads are capped at {{ max_upload_mb }} MB, remote objects at {{ max_remote_mb }} MB,
|
|
224
|
+
up to {{ max_files }} files total.
|
|
225
|
+
</div>
|
|
226
|
+
{% else %}
|
|
227
|
+
<div class="hint">Uploads are capped at {{ max_upload_mb }} MB, up to {{ max_files }} files total.</div>
|
|
228
|
+
{% endif %}
|
|
229
|
+
|
|
230
|
+
<label class="check">
|
|
231
|
+
<input type="checkbox" name="header" value="1">
|
|
232
|
+
First row is a header
|
|
233
|
+
</label>
|
|
234
|
+
<div class="hint">Off by default: every row is diffed as data (columns shown as A, B, C...).</div>
|
|
235
|
+
|
|
236
|
+
<div class="field">
|
|
237
|
+
<label>Row key column <span style="font-weight:400;color:var(--muted)">(optional)</span></label>
|
|
238
|
+
<input type="text" name="key" placeholder="header name, or A/B/C if no header">
|
|
239
|
+
<div class="hint">Matches rows by this column's value instead of row position.</div>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<button type="submit">Compare files</button>
|
|
243
|
+
</form>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<script>
|
|
247
|
+
const REMOTE_ENABLED = {{ remote_enabled | tojson }};
|
|
248
|
+
|
|
249
|
+
function updateFileName(input) {
|
|
250
|
+
const nameEl = input.nextElementSibling;
|
|
251
|
+
if (input.files && input.files.length > 0) {
|
|
252
|
+
nameEl.textContent = input.files[0].name;
|
|
253
|
+
nameEl.classList.add('has-file');
|
|
254
|
+
} else {
|
|
255
|
+
nameEl.textContent = 'No file selected';
|
|
256
|
+
nameEl.classList.remove('has-file');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function wireDragAndDrop(row) {
|
|
261
|
+
const fileInput = row.querySelector('input[type=file]');
|
|
262
|
+
['dragenter', 'dragover'].forEach(evt =>
|
|
263
|
+
row.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); row.classList.add('dragover'); }));
|
|
264
|
+
['dragleave', 'drop'].forEach(evt =>
|
|
265
|
+
row.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); row.classList.remove('dragover'); }));
|
|
266
|
+
row.addEventListener('drop', e => {
|
|
267
|
+
const dt = e.dataTransfer;
|
|
268
|
+
if (dt && dt.files && dt.files.length > 0) {
|
|
269
|
+
fileInput.files = dt.files;
|
|
270
|
+
updateFileName(fileInput);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
document.querySelectorAll('#fileList .file-row').forEach(wireDragAndDrop);
|
|
275
|
+
|
|
276
|
+
function addFile() {
|
|
277
|
+
const list = document.getElementById('fileList');
|
|
278
|
+
const row = document.createElement('div');
|
|
279
|
+
row.className = 'file-row';
|
|
280
|
+
const n = list.children.length + 1;
|
|
281
|
+
row.innerHTML = '<span class="badge">v' + n + '</span>' +
|
|
282
|
+
'<button type="button" class="pick-btn" onclick="this.nextElementSibling.click()">Choose file</button>' +
|
|
283
|
+
'<input type="file" name="files" accept=".xlsx,.xls,.xlsm,.csv" onchange="updateFileName(this)">' +
|
|
284
|
+
'<span class="file-name">No file selected</span>' +
|
|
285
|
+
(REMOTE_ENABLED ? '<span class="row-or">or</span><input type="text" class="remote-url" name="remote_refs" placeholder="s3://bucket/file.xlsx">' : '') +
|
|
286
|
+
'<button type="button" class="remove-file" onclick="this.parentElement.remove()">×</button>';
|
|
287
|
+
list.appendChild(row);
|
|
288
|
+
wireDragAndDrop(row);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
document.querySelector('form').addEventListener('submit', function (e) {
|
|
292
|
+
const rows = document.querySelectorAll('#fileList .file-row');
|
|
293
|
+
for (const row of rows) {
|
|
294
|
+
const file = row.querySelector('input[type=file]');
|
|
295
|
+
const url = row.querySelector('input.remote-url');
|
|
296
|
+
const hasFile = file && file.files && file.files.length > 0;
|
|
297
|
+
const hasUrl = url && url.value.trim() !== '';
|
|
298
|
+
if (!hasFile && !hasUrl) {
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
alert('Each row needs either an uploaded file or a remote URI.');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
function applyTheme(t) {
|
|
307
|
+
if (t) document.documentElement.setAttribute('data-theme', t);
|
|
308
|
+
else document.documentElement.removeAttribute('data-theme');
|
|
309
|
+
}
|
|
310
|
+
function toggleTheme() {
|
|
311
|
+
const current = document.documentElement.getAttribute('data-theme') ||
|
|
312
|
+
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
313
|
+
const next = current === 'dark' ? 'light' : 'dark';
|
|
314
|
+
try { localStorage.setItem('xldiff-theme', next); } catch (e) {}
|
|
315
|
+
applyTheme(next);
|
|
316
|
+
}
|
|
317
|
+
(function () {
|
|
318
|
+
try {
|
|
319
|
+
const saved = localStorage.getItem('xldiff-theme');
|
|
320
|
+
if (saved) applyTheme(saved);
|
|
321
|
+
} catch (e) {}
|
|
322
|
+
})();
|
|
323
|
+
</script>
|
|
324
|
+
</body>
|
|
325
|
+
</html>
|
sheetdiff/webapp.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Web UI: upload two or more spreadsheets, view a GitHub/VSCode-style diff.
|
|
2
|
+
|
|
3
|
+
Files can either be uploaded directly or referenced by a remote URI
|
|
4
|
+
(s3://, minio://, r2://, garage://, az:///azure://) — see storage.py and
|
|
5
|
+
config.py for how remote credentials and size limits are configured.
|
|
6
|
+
|
|
7
|
+
Run: python -m sheetdiff.webapp
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import tempfile
|
|
11
|
+
from flask import Flask, request, render_template
|
|
12
|
+
|
|
13
|
+
from . import config, storage
|
|
14
|
+
from .core import diff_rows_full, _is_excel
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
app = Flask(__name__)
|
|
18
|
+
# Total request body cap. This bounds *uploaded* bytes; remote downloads are
|
|
19
|
+
# capped separately and independently via config.MAX_REMOTE_BYTES, since
|
|
20
|
+
# they never pass through the request body.
|
|
21
|
+
app.config["MAX_CONTENT_LENGTH"] = config.MAX_UPLOAD_BYTES
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_items(rows, context=3):
|
|
25
|
+
"""Collapse long unchanged runs into fold markers + hunk headers, VSCode-diff style."""
|
|
26
|
+
n = len(rows)
|
|
27
|
+
visible = [False] * n
|
|
28
|
+
for i, r in enumerate(rows):
|
|
29
|
+
if r["status"] != "unchanged":
|
|
30
|
+
for j in range(max(0, i - context), min(n, i + context + 1)):
|
|
31
|
+
visible[j] = True
|
|
32
|
+
|
|
33
|
+
items = []
|
|
34
|
+
i = 0
|
|
35
|
+
prev_visible = False
|
|
36
|
+
while i < n:
|
|
37
|
+
if visible[i]:
|
|
38
|
+
if not prev_visible:
|
|
39
|
+
items.append({"type": "hunk", "old_line": rows[i]["old_line"] or 0, "new_line": rows[i]["new_line"] or 0})
|
|
40
|
+
items.append({"type": "row", "row": rows[i]})
|
|
41
|
+
prev_visible = True
|
|
42
|
+
i += 1
|
|
43
|
+
else:
|
|
44
|
+
j = i
|
|
45
|
+
while j < n and not visible[j]:
|
|
46
|
+
j += 1
|
|
47
|
+
items.append({"type": "fold", "count": j - i, "rows": rows[i:j]})
|
|
48
|
+
prev_visible = False
|
|
49
|
+
i = j
|
|
50
|
+
return items
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _sheet_names(path: str):
|
|
54
|
+
if not _is_excel(path):
|
|
55
|
+
return ["sheet"]
|
|
56
|
+
with pd.ExcelFile(path) as xl:
|
|
57
|
+
return xl.sheet_names
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.route("/", methods=["GET"])
|
|
61
|
+
def index():
|
|
62
|
+
return render_template(
|
|
63
|
+
"index.html",
|
|
64
|
+
remote_enabled=bool(config.ALLOWED_REMOTE_SCHEMES),
|
|
65
|
+
allowed_schemes=sorted(config.ALLOWED_REMOTE_SCHEMES),
|
|
66
|
+
max_upload_mb=config.MAX_UPLOAD_MB,
|
|
67
|
+
max_remote_mb=config.MAX_REMOTE_MB,
|
|
68
|
+
max_files=config.MAX_FILES,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.route("/diff", methods=["POST"])
|
|
73
|
+
def diff():
|
|
74
|
+
uploads = request.files.getlist("files")
|
|
75
|
+
remote_refs = request.form.getlist("remote_refs")
|
|
76
|
+
# Every row submits both a (possibly empty) file part and a (possibly
|
|
77
|
+
# empty) remote_refs entry, so the two lists line up 1:1 by row.
|
|
78
|
+
if len(remote_refs) < len(uploads):
|
|
79
|
+
remote_refs = remote_refs + [""] * (len(uploads) - len(remote_refs))
|
|
80
|
+
|
|
81
|
+
slots = [] # list of (source, display_name) where source is a FileStorage or a remote URI string
|
|
82
|
+
for f, ref in zip(uploads, remote_refs):
|
|
83
|
+
ref = (ref or "").strip()
|
|
84
|
+
if ref:
|
|
85
|
+
slots.append((ref, ref))
|
|
86
|
+
elif f and f.filename:
|
|
87
|
+
slots.append((f, f.filename))
|
|
88
|
+
|
|
89
|
+
if len(slots) < 2:
|
|
90
|
+
return "At least two files (uploaded or remote) are required.", 400
|
|
91
|
+
if len(slots) > config.MAX_FILES:
|
|
92
|
+
return (
|
|
93
|
+
f"Too many files: {len(slots)} given, {config.MAX_FILES} allowed "
|
|
94
|
+
"(configure via SHEETDIFF_MAX_FILES).",
|
|
95
|
+
400,
|
|
96
|
+
)
|
|
97
|
+
key = request.form.get("key") or None
|
|
98
|
+
header = bool(request.form.get("header"))
|
|
99
|
+
|
|
100
|
+
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
|
|
101
|
+
paths, names = [], []
|
|
102
|
+
try:
|
|
103
|
+
for i, (source, name) in enumerate(slots):
|
|
104
|
+
if isinstance(source, str):
|
|
105
|
+
p = storage.resolve(source, tmp, max_bytes=config.MAX_REMOTE_BYTES)
|
|
106
|
+
else:
|
|
107
|
+
p = os.path.join(tmp, f"{i}_{source.filename}")
|
|
108
|
+
source.save(p)
|
|
109
|
+
paths.append(p)
|
|
110
|
+
names.append(name)
|
|
111
|
+
except storage.MissingDependencyError as e:
|
|
112
|
+
return str(e), 501
|
|
113
|
+
except storage.SchemeNotAllowedError as e:
|
|
114
|
+
return str(e), 400
|
|
115
|
+
except storage.RemoteFileTooLargeError as e:
|
|
116
|
+
return str(e), 413
|
|
117
|
+
except storage.StorageError as e:
|
|
118
|
+
return str(e), 400
|
|
119
|
+
|
|
120
|
+
sheet_lists = [_sheet_names(p) for p in paths]
|
|
121
|
+
|
|
122
|
+
all_sheets = []
|
|
123
|
+
for sl in sheet_lists:
|
|
124
|
+
for s in sl:
|
|
125
|
+
if s not in all_sheets:
|
|
126
|
+
all_sheets.append(s)
|
|
127
|
+
|
|
128
|
+
presence = {s: [s in sl for sl in sheet_lists] for s in all_sheets}
|
|
129
|
+
|
|
130
|
+
# --- Unified view: pairwise consecutive comparisons ---
|
|
131
|
+
pairs = []
|
|
132
|
+
for i in range(len(paths) - 1):
|
|
133
|
+
pair_sheets = {}
|
|
134
|
+
for s in all_sheets:
|
|
135
|
+
l_ok, r_ok = presence[s][i], presence[s][i + 1]
|
|
136
|
+
if not l_ok and not r_ok:
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
d = diff_rows_full(
|
|
140
|
+
paths[i], paths[i + 1], key=key, sheet_name=s, header=header,
|
|
141
|
+
left_exists=l_ok, right_exists=r_ok,
|
|
142
|
+
)
|
|
143
|
+
d["diff_items"] = _build_items(d["rows"])
|
|
144
|
+
d["added"] = sum(1 for r in d["rows"] if r["status"] == "added")
|
|
145
|
+
d["removed"] = sum(1 for r in d["rows"] if r["status"] == "removed")
|
|
146
|
+
d["modified"] = sum(1 for r in d["rows"] if r["status"] == "modified")
|
|
147
|
+
d["sheet_status"] = "both" if (l_ok and r_ok) else ("added" if r_ok else "removed")
|
|
148
|
+
pair_sheets[s] = d
|
|
149
|
+
except Exception as e:
|
|
150
|
+
pair_sheets[s] = {"columns": [], "rows": [], "diff_items": [], "error": str(e), "sheet_status": "both"}
|
|
151
|
+
pairs.append({"left_name": names[i], "right_name": names[i + 1], "sheets": pair_sheets})
|
|
152
|
+
|
|
153
|
+
return render_template(
|
|
154
|
+
"diff.html",
|
|
155
|
+
file_names=names,
|
|
156
|
+
pairs=pairs,
|
|
157
|
+
multi_file=len(paths) > 2,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.errorhandler(413)
|
|
162
|
+
def too_large(_e):
|
|
163
|
+
return (
|
|
164
|
+
f"Upload too large. The server accepts at most {config.MAX_UPLOAD_MB} MB "
|
|
165
|
+
"per request (configure via SHEETDIFF_MAX_UPLOAD_MB), or use a remote:// "
|
|
166
|
+
"URI so the file doesn't have to pass through the browser.",
|
|
167
|
+
413,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main():
|
|
172
|
+
app.run(debug=True, port=5000)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
main()
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sheetdiff
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Concise spreadsheet diff utility (CSV/XLSX) with an optional GitHub/VS Code-style web UI
|
|
5
|
+
Author-email: Muhammad Umer <muhammadumer0266@gmail.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Muhammad Umer
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/muhammadumer0266/xl-diff
|
|
29
|
+
Project-URL: Repository, https://github.com/muhammadumer0266/xl-diff
|
|
30
|
+
Project-URL: Issues, https://github.com/muhammadumer0266/xl-diff/issues
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
34
|
+
Classifier: Operating System :: OS Independent
|
|
35
|
+
Classifier: Topic :: Office/Business
|
|
36
|
+
Classifier: Topic :: Utilities
|
|
37
|
+
Requires-Python: >=3.10
|
|
38
|
+
Description-Content-Type: text/markdown
|
|
39
|
+
License-File: LICENSE
|
|
40
|
+
Requires-Dist: pandas>=1.5
|
|
41
|
+
Requires-Dist: openpyxl>=3.0
|
|
42
|
+
Provides-Extra: web
|
|
43
|
+
Requires-Dist: flask>=2.0; extra == "web"
|
|
44
|
+
Provides-Extra: remote
|
|
45
|
+
Requires-Dist: fsspec>=2023.1; extra == "remote"
|
|
46
|
+
Requires-Dist: s3fs>=2023.1; extra == "remote"
|
|
47
|
+
Requires-Dist: adlfs>=2023.1; extra == "remote"
|
|
48
|
+
Provides-Extra: all
|
|
49
|
+
Requires-Dist: sheetdiff[remote,web]; extra == "all"
|
|
50
|
+
Dynamic: license-file
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# xl-diff (sheetdiff)
|
|
54
|
+
|
|
55
|
+
Minimal, efficient spreadsheet diff tool. Load CSV/XLSX and produce unified +/- row and cell diffs.
|
|
56
|
+
|
|
57
|
+
## Run locally from a clone (web UI)
|
|
58
|
+
|
|
59
|
+
If you just want to clone the repo and use the web UI on your own machine, without publishing to PyPI:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/muhammadumer0266/xl-diff.git
|
|
63
|
+
cd xl-diff
|
|
64
|
+
|
|
65
|
+
# Create and activate a virtualenv
|
|
66
|
+
python -m venv .venv
|
|
67
|
+
# macOS/Linux:
|
|
68
|
+
source .venv/bin/activate
|
|
69
|
+
# Windows (PowerShell):
|
|
70
|
+
.venv\Scripts\Activate.ps1
|
|
71
|
+
|
|
72
|
+
# Install the package in editable mode with the web extra
|
|
73
|
+
pip install -e ".[web]"
|
|
74
|
+
|
|
75
|
+
# Start the web server
|
|
76
|
+
xldiff-web
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then open http://localhost:5000, upload two spreadsheets (optionally a key column), and view a GitHub-style row/cell diff (green = added, red = removed, yellow = modified with old value struck through above the new one).
|
|
80
|
+
|
|
81
|
+
The pure-Python (pandas-based) diff engine works out of the box after `pip install -e ".[web]"`. If you also want the faster Rust-backed engine, additionally run `maturin develop --release` (see "Building the Rust extension" below) before starting `xldiff-web`.
|
|
82
|
+
|
|
83
|
+
## Install from PyPI (distributed as `sheetdiff`)
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
pip install sheetdiff
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Quick CLI
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
python -m sheetdiff.cli left.xlsx right.xlsx --key id
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Web UI (GitHub-style diff viewer)
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
pip install sheetdiff[web]
|
|
99
|
+
xldiff-web
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Open http://localhost:5000, upload two spreadsheets (optionally a key column), and view a GitHub-style row/cell diff (green = added, red = removed, yellow = modified with old value struck through above the new one).
|
|
103
|
+
|
|
104
|
+
Remote storage (S3, MinIO, Cloudflare R2, Garage, Azure Blob)
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
pip install sheetdiff[remote]
|
|
108
|
+
sheetdiff s3://my-bucket/left.xlsx s3://my-bucket/right.xlsx --key id
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Files can also be referenced by URI in the web UI (each row accepts an upload *or* a URI) and from the Python API via `sheetdiff.resolve_source`. Credentials and per-backend endpoints are configured once via environment variables, never through the web form — see [docs/REMOTE_STORAGE.md](docs/REMOTE_STORAGE.md) for the full list, plus how to configure upload/remote size limits (`SHEETDIFF_MAX_UPLOAD_MB`, `SHEETDIFF_MAX_REMOTE_MB`, `SHEETDIFF_MAX_FILES`).
|
|
112
|
+
|
|
113
|
+
Python API
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
from sheetdiff.core import diff_sheets, format_unified
|
|
117
|
+
|
|
118
|
+
changes = diff_sheets('left.csv','right.csv', key='id')
|
|
119
|
+
print(format_unified(changes))
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Functions
|
|
123
|
+
|
|
124
|
+
- `diff_sheets(left, right, key=None, sheet_name=None)` — compare two files; if `key` provided, rows are matched by that column, otherwise by position. Returns list of tuples `(type, row_key_or_index, column, old, new)`.
|
|
125
|
+
- `format_unified(changes)` — render a compact unified +/- text view.
|
|
126
|
+
|
|
127
|
+
Integration notes
|
|
128
|
+
|
|
129
|
+
- For web backends (Django/Celery), call `diff_sheets` or `compare_workbooks` inside a worker and serialize `changes` to JSON for paginated display.
|
|
130
|
+
- The package will prefer a Rust-backed binary extension `xl_diff` when available (faster, memory-efficient). If the Rust extension is not installed, the pure-Python fallback (`pandas` based) is used.
|
|
131
|
+
- For very large files implement the Rust core (see `RUST_INTEGRATION.md`) and build wheels via `maturin` for best performance.
|
|
132
|
+
|
|
133
|
+
# xl-diff
|
|
134
|
+
|
|
135
|
+
[](https://github.com/muhammadumer0266/xl-diff/actions/workflows/CI.yml)
|
|
136
|
+
[](https://crates.io/crates/xl_diff)
|
|
137
|
+
[](https://pypi.org/project/sheetdiff/)
|
|
138
|
+
[](https://www.rust-lang.org/)
|
|
139
|
+
[](LICENSE)
|
|
140
|
+
|
|
141
|
+
xl-diff is a high-performance, memory-safe Excel comparison engine written in Rust and exposed as a Python extension via PyO3. It is designed to integrate into Django/Celery pipelines and produce cell-level semantic diffs between spreadsheet versions.
|
|
142
|
+
|
|
143
|
+
Features
|
|
144
|
+
- Fast, zero-copy reading of XLSX using `calamine`.
|
|
145
|
+
- Parallel diff computation using `rayon`.
|
|
146
|
+
- Row alignment using an optional key column or positional fallback.
|
|
147
|
+
- Python bindings (PyO3) for seamless integration.
|
|
148
|
+
- Cross-platform packaging with `maturin` and GitHub Actions.
|
|
149
|
+
|
|
150
|
+
## Building the Rust extension
|
|
151
|
+
|
|
152
|
+
The package prefers a Rust-backed binary extension (`xl_diff`, faster and more memory-efficient) when available, falling back to the pure-Python implementation otherwise. To build it in-place inside your virtualenv:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
pip install maturin
|
|
156
|
+
maturin develop --release
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Python usage
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
import xl_diff
|
|
163
|
+
|
|
164
|
+
# List sheets
|
|
165
|
+
sheets = xl_diff.get_sheet_names("/path/to/file.xlsx")
|
|
166
|
+
|
|
167
|
+
# Diff two sheets by key column 0
|
|
168
|
+
deltas = xl_diff.diff_sheets(
|
|
169
|
+
"/path/to/old.xlsx",
|
|
170
|
+
"Sheet1",
|
|
171
|
+
"/path/to/new.xlsx",
|
|
172
|
+
"Sheet1",
|
|
173
|
+
0,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
for d in deltas:
|
|
177
|
+
print(d.row_idx_old, d.row_idx_new, d.col_idx, d.status, d.old_value, d.new_value)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Badge & CI
|
|
181
|
+
|
|
182
|
+
This repository includes GitHub Actions workflows to build manylinux/musllinux and wheels for Windows/macOS, along with an sdist job. The CI badge above links to the main workflow.
|
|
183
|
+
|
|
184
|
+
Contributing
|
|
185
|
+
|
|
186
|
+
Please open issues for bugs and feature requests. Pull requests should target the `main` branch; the `issue/traceability-phase2` branch contains traceability and test improvements.
|
|
187
|
+
|
|
188
|
+
License
|
|
189
|
+
|
|
190
|
+
MIT
|