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.
@@ -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">&#9728;&#65039; / &#127769;</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()">&times;</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
+ [![CI](https://github.com/muhammadumer0266/xl-diff/actions/workflows/CI.yml/badge.svg)](https://github.com/muhammadumer0266/xl-diff/actions/workflows/CI.yml)
136
+ [![crates.io](https://img.shields.io/crates/v/xl_diff.svg)](https://crates.io/crates/xl_diff)
137
+ [![PyPI](https://img.shields.io/pypi/v/sheetdiff.svg)](https://pypi.org/project/sheetdiff/)
138
+ [![Language](https://img.shields.io/badge/Language-Rust%20%2F%20Python-orange.svg)](https://www.rust-lang.org/)
139
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](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