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/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