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
sheetdiff/storage.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Resolve a spreadsheet reference — local path or remote URI — to a local
|
|
2
|
+
file that the rest of sheetdiff (pandas / the Rust core) can read.
|
|
3
|
+
|
|
4
|
+
Supported remote schemes: s3://, minio://, r2://, garage:// (all S3-compatible,
|
|
5
|
+
routed through `s3fs` with a per-scheme endpoint/credentials from
|
|
6
|
+
`sheetdiff.config`), and az:// / azure:// (Azure Blob Storage via `adlfs`).
|
|
7
|
+
|
|
8
|
+
Design notes / edge cases handled here (see docs/EDGE_CASES.md for the full
|
|
9
|
+
list this project tracks):
|
|
10
|
+
|
|
11
|
+
- Windows local paths (`C:\\foo\\bar.xlsx`) must not be mistaken for a URI
|
|
12
|
+
scheme — `_split_scheme` only treats a prefix as a scheme if it's followed
|
|
13
|
+
by `://`, so a single-letter drive + `:` never matches.
|
|
14
|
+
- Only an explicit allow-list of schemes is dispatched remotely (see
|
|
15
|
+
`config.ALLOWED_REMOTE_SCHEMES`); everything else, including arbitrary
|
|
16
|
+
http(s) URLs, is rejected so the diff endpoint can't be turned into an
|
|
17
|
+
open SSRF proxy.
|
|
18
|
+
- Size is checked *before* downloading whenever the backend can report it
|
|
19
|
+
(`fs.info(path)["size"]`), and the download is additionally capped while
|
|
20
|
+
streaming in case the reported size was wrong or missing — this is the
|
|
21
|
+
remote-storage analogue of the "zip bomb" / "very large sheet" cases in
|
|
22
|
+
EDGE_CASES.md.
|
|
23
|
+
- Missing optional dependency (`fsspec`/`s3fs`/`adlfs` not installed) raises
|
|
24
|
+
a clear, actionable error instead of an opaque ImportError deep in fsspec.
|
|
25
|
+
- Every temp file this module creates is tracked so callers can clean up
|
|
26
|
+
even when an exception happens partway through a multi-file batch.
|
|
27
|
+
"""
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import uuid
|
|
31
|
+
from typing import List, Optional, Tuple
|
|
32
|
+
|
|
33
|
+
from . import config
|
|
34
|
+
|
|
35
|
+
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.\-]*)://")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StorageError(Exception):
|
|
39
|
+
"""Base class for remote-storage resolution failures."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SchemeNotAllowedError(StorageError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MissingDependencyError(StorageError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RemoteFileTooLargeError(StorageError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RemoteReadError(StorageError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _split_scheme(ref: str) -> Optional[str]:
|
|
59
|
+
m = _SCHEME_RE.match(ref)
|
|
60
|
+
return m.group(1).lower() if m else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_remote_ref(ref: str) -> bool:
|
|
64
|
+
return _split_scheme(ref) in config.ALLOWED_REMOTE_SCHEMES
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _get_filesystem(scheme: str):
|
|
68
|
+
backend = config.backend_for_scheme(scheme)
|
|
69
|
+
if backend is None:
|
|
70
|
+
raise SchemeNotAllowedError(
|
|
71
|
+
f"Remote scheme '{scheme}://' is not enabled. Allowed schemes: "
|
|
72
|
+
f"{', '.join(sorted(config.ALLOWED_REMOTE_SCHEMES)) or '(none configured)'}"
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
import fsspec
|
|
76
|
+
except ImportError as e:
|
|
77
|
+
raise MissingDependencyError(
|
|
78
|
+
"Remote storage support requires the 'remote' extra: "
|
|
79
|
+
"pip install xl-diff[remote]"
|
|
80
|
+
) from e
|
|
81
|
+
try:
|
|
82
|
+
fs = fsspec.filesystem(
|
|
83
|
+
backend.protocol,
|
|
84
|
+
**backend.storage_options,
|
|
85
|
+
connect_timeout=config.REMOTE_CONNECT_TIMEOUT,
|
|
86
|
+
read_timeout=config.REMOTE_READ_TIMEOUT,
|
|
87
|
+
)
|
|
88
|
+
except TypeError:
|
|
89
|
+
# Some fsspec backends don't accept timeout kwargs directly.
|
|
90
|
+
fs = fsspec.filesystem(backend.protocol, **backend.storage_options)
|
|
91
|
+
return fs
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _strip_scheme(ref: str, scheme: str) -> str:
|
|
95
|
+
return ref[len(scheme) + 3:] # drop "<scheme>://"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def fetch_to_local(ref: str, dest_dir: str, max_bytes: Optional[int] = None) -> str:
|
|
99
|
+
"""Download a remote object to `dest_dir` and return the local path.
|
|
100
|
+
|
|
101
|
+
Raises RemoteFileTooLargeError if the object's reported (or actual,
|
|
102
|
+
streamed) size exceeds `max_bytes` (defaults to config.MAX_REMOTE_BYTES).
|
|
103
|
+
"""
|
|
104
|
+
scheme = _split_scheme(ref)
|
|
105
|
+
if scheme is None:
|
|
106
|
+
raise StorageError(f"'{ref}' is not a remote URI (missing scheme://)")
|
|
107
|
+
limit = config.MAX_REMOTE_BYTES if max_bytes is None else max_bytes
|
|
108
|
+
|
|
109
|
+
fs = _get_filesystem(scheme)
|
|
110
|
+
remote_path = _strip_scheme(ref, scheme)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
info = fs.info(remote_path)
|
|
114
|
+
size = info.get("size")
|
|
115
|
+
except Exception as e:
|
|
116
|
+
raise RemoteReadError(f"Could not stat '{ref}': {e}") from e
|
|
117
|
+
|
|
118
|
+
if size is not None and size > limit:
|
|
119
|
+
raise RemoteFileTooLargeError(
|
|
120
|
+
f"'{ref}' is {size / (1024 * 1024):.1f} MB, which exceeds the "
|
|
121
|
+
f"{limit / (1024 * 1024):.0f} MB remote file size limit "
|
|
122
|
+
f"(configure via SHEETDIFF_MAX_REMOTE_MB)."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
basename = os.path.basename(remote_path) or f"remote-{uuid.uuid4().hex}"
|
|
126
|
+
local_path = os.path.join(dest_dir, f"{uuid.uuid4().hex}_{basename}")
|
|
127
|
+
|
|
128
|
+
written = 0
|
|
129
|
+
chunk_size = 8 * 1024 * 1024
|
|
130
|
+
try:
|
|
131
|
+
with fs.open(remote_path, "rb") as src, open(local_path, "wb") as dst:
|
|
132
|
+
while True:
|
|
133
|
+
chunk = src.read(chunk_size)
|
|
134
|
+
if not chunk:
|
|
135
|
+
break
|
|
136
|
+
written += len(chunk)
|
|
137
|
+
if written > limit:
|
|
138
|
+
raise RemoteFileTooLargeError(
|
|
139
|
+
f"'{ref}' exceeded the {limit / (1024 * 1024):.0f} MB "
|
|
140
|
+
"remote file size limit while streaming (actual size "
|
|
141
|
+
"was larger than reported)."
|
|
142
|
+
)
|
|
143
|
+
dst.write(chunk)
|
|
144
|
+
except RemoteFileTooLargeError:
|
|
145
|
+
_safe_remove(local_path)
|
|
146
|
+
raise
|
|
147
|
+
except FileNotFoundError as e:
|
|
148
|
+
_safe_remove(local_path)
|
|
149
|
+
raise RemoteReadError(f"'{ref}' was not found") from e
|
|
150
|
+
except Exception as e:
|
|
151
|
+
_safe_remove(local_path)
|
|
152
|
+
raise RemoteReadError(f"Failed to download '{ref}': {e}") from e
|
|
153
|
+
|
|
154
|
+
return local_path
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _safe_remove(path: str) -> None:
|
|
158
|
+
try:
|
|
159
|
+
os.remove(path)
|
|
160
|
+
except OSError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def resolve(ref: str, dest_dir: str, max_bytes: Optional[int] = None) -> str:
|
|
165
|
+
"""Resolve `ref` (local path or remote URI) to a local, readable path."""
|
|
166
|
+
if is_remote_ref(ref):
|
|
167
|
+
return fetch_to_local(ref, dest_dir, max_bytes=max_bytes)
|
|
168
|
+
scheme = _split_scheme(ref)
|
|
169
|
+
if scheme is not None:
|
|
170
|
+
raise SchemeNotAllowedError(
|
|
171
|
+
f"'{ref}' uses scheme '{scheme}://', which is not an allowed "
|
|
172
|
+
f"remote scheme. Allowed: {', '.join(sorted(config.ALLOWED_REMOTE_SCHEMES))}"
|
|
173
|
+
)
|
|
174
|
+
if not os.path.exists(ref):
|
|
175
|
+
raise StorageError(f"Local file not found: '{ref}'")
|
|
176
|
+
return ref
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def resolve_many(
|
|
180
|
+
refs: List[str], dest_dir: str, max_bytes: Optional[int] = None
|
|
181
|
+
) -> Tuple[List[str], List[str]]:
|
|
182
|
+
"""Resolve several refs; returns (local_paths, downloaded_paths).
|
|
183
|
+
|
|
184
|
+
`downloaded_paths` is the subset the caller is responsible for cleaning
|
|
185
|
+
up (local, already-on-disk paths are left untouched); if this raises
|
|
186
|
+
partway through, everything downloaded so far is cleaned up automatically.
|
|
187
|
+
"""
|
|
188
|
+
local_paths: List[str] = []
|
|
189
|
+
downloaded: List[str] = []
|
|
190
|
+
try:
|
|
191
|
+
for ref in refs:
|
|
192
|
+
before = is_remote_ref(ref)
|
|
193
|
+
path = resolve(ref, dest_dir, max_bytes=max_bytes)
|
|
194
|
+
local_paths.append(path)
|
|
195
|
+
if before:
|
|
196
|
+
downloaded.append(path)
|
|
197
|
+
except Exception:
|
|
198
|
+
for p in downloaded:
|
|
199
|
+
_safe_remove(p)
|
|
200
|
+
raise
|
|
201
|
+
return local_paths, downloaded
|