steam-data-reader 1.0.0.dev1__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,271 @@
1
+ """Installed-code SQL only. Downloaded files supply data, never SQL or executable databases."""
2
+
3
+ import hashlib
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import duckdb
8
+ from steam_data_schema import (
9
+ APP_COLUMNS,
10
+ DESCRIPTIONS,
11
+ INFO_COLUMNS,
12
+ JSON_FIELDS,
13
+ TABLES,
14
+ TEXT_FIELDS,
15
+ canonical,
16
+ )
17
+
18
+
19
+ def connect(path, *, read_only=False, memory_limit="768MB"):
20
+ connection = duckdb.connect(
21
+ str(path),
22
+ read_only=read_only,
23
+ config={
24
+ "autoinstall_known_extensions": "false",
25
+ "autoload_known_extensions": "false",
26
+ "threads": "2",
27
+ "memory_limit": memory_limit,
28
+ },
29
+ )
30
+ connection.execute("SET TimeZone='UTC'")
31
+ return connection
32
+
33
+
34
+ def initialize(c):
35
+ c.execute("CREATE SCHEMA transport; CREATE SCHEMA steam")
36
+ for name, spec in TABLES.items():
37
+ cols = ",".join(
38
+ f'"{n}" {t}' + (" NOT NULL" if n in spec["key"] else "")
39
+ for n, t in spec["columns"].items()
40
+ )
41
+ # ART primary-key indexes cannot spill and exceed modest memory budgets
42
+ # for millions of provenance rows. apply_tables validates incoming keys;
43
+ # validate_state also checks the complete state using spillable SQL.
44
+ c.execute(f"CREATE TABLE transport.{name} ({cols})")
45
+ c.execute(
46
+ "CREATE TABLE steam.snapshot_info ("
47
+ + ",".join(f"{n} {t}" for n, t in INFO_COLUMNS.items())
48
+ + ")"
49
+ )
50
+ for name in TABLES:
51
+ if not name.startswith("app_") or name in (
52
+ "app_organizations",
53
+ "app_relationships",
54
+ "app_genres",
55
+ "app_categories",
56
+ "app_tags",
57
+ ):
58
+ c.execute(f"CREATE VIEW steam.{name} AS SELECT * FROM transport.{name}")
59
+ selections = {
60
+ "steam_appid": "c.steam_appid",
61
+ "name": "coalesce(d.name,c.catalog_name)",
62
+ "steam_url": "'https://store.steampowered.com/app/' || c.steam_appid",
63
+ "name_origin": "CASE WHEN d.name IS NOT NULL THEN 'details' WHEN c.catalog_name IS NOT NULL THEN 'catalog' ELSE 'unknown' END",
64
+ "first_seen_at": "c.first_seen_at",
65
+ "last_seen_at": "c.last_seen_at",
66
+ "is_free": "d.is_free",
67
+ "default_review_series_id": "i.default_review_series_id",
68
+ "reviews_observed_at": "r.observed_at",
69
+ "price_country": "p.country",
70
+ "price_currency": "p.currency",
71
+ "price_initial_minor": "p.initial_minor",
72
+ "price_final_minor": "p.final_minor",
73
+ "price_discount_percentage": "p.discount_percentage",
74
+ "price_observed_at": "p.observed_at",
75
+ }
76
+ selections.update({f: "d." + f for f in TEXT_FIELDS + JSON_FIELDS if f != "name"})
77
+ selections.update({f: "x." + f for f in DESCRIPTIONS})
78
+ selections.update(
79
+ {f: "s." + f for f in ("developers", "publishers", "genres", "categories", "tags")}
80
+ )
81
+ selections.update(
82
+ {
83
+ f: "r." + f
84
+ for f in (
85
+ "total_positive",
86
+ "total_negative",
87
+ "total_reviews",
88
+ "positive_percentage",
89
+ "review_score",
90
+ "review_score_desc",
91
+ )
92
+ }
93
+ )
94
+ c.execute(
95
+ "CREATE VIEW steam.apps AS SELECT "
96
+ + ",".join(f'{selections[f]} AS "{f}"' for f in APP_COLUMNS)
97
+ + """
98
+ FROM transport.app_catalog c CROSS JOIN steam.snapshot_info i
99
+ LEFT JOIN transport.app_details d USING(steam_appid)
100
+ LEFT JOIN transport.app_descriptions x USING(steam_appid)
101
+ LEFT JOIN transport.app_sets s USING(steam_appid)
102
+ LEFT JOIN transport.review_summaries r ON r.steam_appid=c.steam_appid AND r.review_series_id=i.default_review_series_id
103
+ LEFT JOIN transport.prices p ON p.steam_appid=c.steam_appid AND p.country=i.price_country AND p.language=i.price_language
104
+ """
105
+ )
106
+
107
+
108
+ def set_info(c, m):
109
+ info = {f: m.get(f) for f in INFO_COLUMNS}
110
+ info.update(
111
+ dataset_version=m["dataset_version"],
112
+ coverage_json=canonical(m["coverage"]).decode(),
113
+ feature_support_json=canonical(m["feature_support"]).decode(),
114
+ freshness_json=canonical(m.get("freshness", {})).decode(),
115
+ )
116
+ c.execute("DELETE FROM steam.snapshot_info")
117
+ c.execute(
118
+ "INSERT INTO steam.snapshot_info VALUES (" + ",".join("?" for _ in info) + ")",
119
+ list(info.values()),
120
+ )
121
+
122
+
123
+ def decoded(row, columns):
124
+ return {
125
+ name: json.loads(value) if kind == "JSON" and value is not None else value
126
+ for (name, kind), value in zip(columns.items(), row)
127
+ }
128
+
129
+
130
+ def app_ranges(c, size=4096):
131
+ """Inclusive upper bounds covering all numeric appIDs, including removed apps."""
132
+ bounds = c.execute(
133
+ "SELECT max(steam_appid) FROM (SELECT steam_appid,(row_number() OVER(ORDER BY steam_appid)-1)//? AS batch FROM transport.app_catalog) GROUP BY batch ORDER BY batch",
134
+ [size],
135
+ ).fetchall()
136
+ lower = None
137
+ for (upper,) in bounds[:-1]:
138
+ yield lower, upper
139
+ lower = upper
140
+ yield lower, None
141
+
142
+
143
+ def range_filter(bounds, alias=""):
144
+ lower, upper = bounds
145
+ col = alias + "steam_appid"
146
+ return (
147
+ " AND ".join(
148
+ ([f"{col}>{int(lower)}"] if lower is not None else [])
149
+ + ([f"{col}<={int(upper)}"] if upper is not None else [])
150
+ )
151
+ or "true"
152
+ )
153
+
154
+
155
+ def logical_hash(c, *, app_batch_size=None):
156
+ digest = hashlib.sha256()
157
+ ranges = list(app_ranges(c, app_batch_size)) if app_batch_size else [(None, None)]
158
+ for name, spec in sorted(TABLES.items()):
159
+ # DuckDB cursor() creates an independent connection, outside this transaction.
160
+ cur = c
161
+ for bounds in ranges if "steam_appid" in spec["columns"] else [(None, None)]:
162
+ where = range_filter(bounds) if "steam_appid" in spec["columns"] else "true"
163
+ cur.execute(
164
+ f"SELECT * FROM transport.{name} WHERE {where} ORDER BY " + ",".join(spec["key"])
165
+ )
166
+ while batch := cur.fetchmany(1000):
167
+ for row in batch:
168
+ digest.update(canonical([name, decoded(row, spec["columns"])]))
169
+ digest.update(b"\n")
170
+ return digest.hexdigest()
171
+
172
+
173
+ def apply_tables(c, root, m):
174
+ for name, spec in TABLES.items():
175
+ columns = spec["columns"]
176
+ c.execute(
177
+ f"CREATE TEMP TABLE incoming_{name} AS SELECT *, ''::VARCHAR AS _op FROM transport.{name} WHERE false"
178
+ )
179
+ for f in m["tables"][name]["files"]:
180
+ path = str(Path(root) / f["path"])
181
+ selected = (
182
+ columns if f["operation"] == "upsert" else {k: columns[k] for k in spec["key"]}
183
+ )
184
+ description = c.execute("DESCRIBE SELECT * FROM read_parquet(?)", [path]).fetchall()
185
+ actual = {
186
+ r[0]: "TIMESTAMPTZ"
187
+ if r[1] == "TIMESTAMP WITH TIME ZONE"
188
+ else "VARCHAR"
189
+ if r[1] == "JSON"
190
+ else r[1]
191
+ for r in description
192
+ }
193
+ expected = {k: "VARCHAR" if v == "JSON" else v for k, v in selected.items()}
194
+ if actual != expected:
195
+ raise ValueError(f"Parquet schema mismatch for {name}")
196
+ count = c.execute("SELECT count(*) FROM read_parquet(?)", [path]).fetchone()[0]
197
+ if count != f["rows"]:
198
+ raise ValueError("Parquet row count mismatch")
199
+ target = ",".join(selected)
200
+ c.execute(
201
+ f"INSERT INTO incoming_{name} ({target},_op) SELECT {target}, ? FROM read_parquet(?)",
202
+ [f["operation"], path],
203
+ )
204
+ keys = ",".join(spec["key"])
205
+ nulls = " OR ".join(k + " IS NULL" for k in spec["key"])
206
+ if c.execute(f"SELECT count(*) FROM incoming_{name} WHERE {nulls}").fetchone()[0]:
207
+ raise ValueError("Null logical key")
208
+ if c.execute(
209
+ f"SELECT 1 FROM incoming_{name} GROUP BY {keys} HAVING count(*)>1 LIMIT 1"
210
+ ).fetchone():
211
+ raise ValueError("Duplicate operation for logical key")
212
+ match = " AND ".join(f"a.{k}=b.{k}" for k in spec["key"])
213
+ c.execute(f"DELETE FROM transport.{name} a USING incoming_{name} b WHERE {match}")
214
+ c.execute(
215
+ f"INSERT INTO transport.{name} SELECT "
216
+ + ",".join(columns)
217
+ + f" FROM incoming_{name} WHERE _op='upsert'"
218
+ )
219
+ c.execute(f"DROP TABLE incoming_{name}")
220
+
221
+
222
+ def validate_state(c, m, *, verify_hash=True, app_batch_size=None):
223
+ count = c.execute("SELECT count(*) FROM transport.app_catalog").fetchone()[0]
224
+ if count != m["app_count"]:
225
+ raise ValueError("Catalog count mismatch")
226
+ ranges = list(app_ranges(c, app_batch_size)) if app_batch_size else [(None, None)]
227
+ for name, spec in TABLES.items():
228
+ keys = ",".join(spec["key"])
229
+ for bounds in ranges if "steam_appid" in spec["columns"] else [(None, None)]:
230
+ where = range_filter(bounds) if "steam_appid" in spec["columns"] else "true"
231
+ if c.execute(
232
+ f"SELECT 1 FROM transport.{name} WHERE {where} GROUP BY {keys} HAVING count(*)>1 LIMIT 1"
233
+ ).fetchone():
234
+ raise ValueError("Duplicate current logical key")
235
+ if (
236
+ c.execute(f"SELECT count(*) FROM transport.{name}").fetchone()[0]
237
+ != m["tables"][name]["rows"]
238
+ ):
239
+ raise ValueError("Current table row-count mismatch")
240
+ if (
241
+ "steam_appid" in spec["columns"]
242
+ and name != "app_catalog"
243
+ and c.execute(
244
+ f"SELECT 1 FROM transport.{name} a ANTI JOIN transport.app_catalog c USING(steam_appid) LIMIT 1"
245
+ ).fetchone()
246
+ ):
247
+ raise ValueError("Orphan app reference")
248
+ for plural, singular in (("genres", "genre"), ("categories", "category"), ("tags", "tag")):
249
+ key = singular + "_id"
250
+ if c.execute(
251
+ f"SELECT 1 FROM transport.app_{plural} ANTI JOIN transport.{plural} USING({key}) LIMIT 1"
252
+ ).fetchone():
253
+ raise ValueError("Orphan classification reference")
254
+ if c.execute(
255
+ "SELECT 1 FROM transport.review_summaries WHERE total_positive<0 OR total_negative<0 OR total_reviews<0 OR total_positive+total_negative!=total_reviews OR total_reviews IS NULL OR total_positive IS NULL OR total_negative IS NULL OR (total_reviews=0 AND positive_percentage IS NOT NULL) OR (total_reviews>0 AND (positive_percentage IS NULL OR abs(positive_percentage-100.0*total_positive/total_reviews)>1e-9)) LIMIT 1"
256
+ ).fetchone():
257
+ raise ValueError("Invalid review metrics")
258
+ if c.execute(
259
+ "SELECT 1 FROM transport.review_summaries ANTI JOIN transport.review_series USING(review_series_id) LIMIT 1"
260
+ ).fetchone():
261
+ raise ValueError("Unknown review series")
262
+ if c.execute(
263
+ "SELECT 1 FROM transport.prices GROUP BY steam_appid,country,language HAVING count(*)>1 LIMIT 1"
264
+ ).fetchone():
265
+ raise ValueError("Ambiguous current price context")
266
+ if c.execute(
267
+ "SELECT 1 FROM transport.prices WHERE currency IS NULL OR currency='' OR initial_minor<0 OR final_minor<0 OR discount_percentage NOT BETWEEN 0 AND 100 LIMIT 1"
268
+ ).fetchone():
269
+ raise ValueError("Invalid current price")
270
+ if verify_hash and logical_hash(c, app_batch_size=app_batch_size) != m["logical_state_sha256"]:
271
+ raise ValueError("Logical state checksum mismatch")
@@ -0,0 +1,333 @@
1
+ """Verified HTTPS acquisition and atomic data-only snapshot installation."""
2
+
3
+ import hashlib
4
+ import ipaddress
5
+ import json
6
+ import os
7
+ import shutil
8
+ import stat
9
+ import tempfile
10
+ import urllib.parse
11
+ import urllib.request
12
+ import zipfile
13
+ from dataclasses import asdict, dataclass
14
+ from pathlib import Path
15
+
16
+ from steam_data_schema import (
17
+ DATASET_ID,
18
+ READER_VERSION,
19
+ canonical,
20
+ check_compatibility,
21
+ sha256,
22
+ validate_manifest,
23
+ )
24
+
25
+ from .catalog import dataset_lock, staged_text
26
+ from .database import apply_tables, connect, initialize, set_info, validate_state
27
+
28
+ MAX_JSON = 4 * 1024**2
29
+ MAX_BUNDLE = 8 * 1024**3
30
+ MAX_EXPANDED = 20 * 1024**3
31
+ DEFAULT_SOURCE_URL = "https://pub-93fc6f5a294444c1adec0db48e691e3a.r2.dev/latest.json"
32
+
33
+
34
+ class SyncError(RuntimeError):
35
+ def __init__(self, message, *, installed_version=None, target_version=None, progress=None):
36
+ super().__init__(message)
37
+ self.installed_version, self.target_version, self.progress = (
38
+ installed_version,
39
+ target_version,
40
+ progress,
41
+ )
42
+
43
+
44
+ class ReaderUpgradeRequired(SyncError):
45
+ pass
46
+
47
+
48
+ @dataclass
49
+ class SyncResult:
50
+ previous_version: str | None
51
+ installed_version: str | None
52
+ target_version: str | None
53
+ bytes_downloaded: int = 0
54
+ applied_increments: int = 0
55
+ baseline_replaced: bool = False
56
+ complete: bool = False
57
+
58
+ def to_dict(self):
59
+ return asdict(self)
60
+
61
+
62
+ def checked_url(url, allow_local_http=False):
63
+ parsed = urllib.parse.urlsplit(url)
64
+ local = False
65
+ try:
66
+ local = ipaddress.ip_address(parsed.hostname or "").is_loopback
67
+ except ValueError:
68
+ local = parsed.hostname == "localhost"
69
+ if (
70
+ (parsed.scheme != "https" and not (allow_local_http and parsed.scheme == "http" and local))
71
+ or parsed.username
72
+ or parsed.password
73
+ or not parsed.hostname
74
+ ):
75
+ raise ValueError(
76
+ "Download URLs must use HTTPS (explicit loopback HTTP is for private testing only)"
77
+ )
78
+ return url
79
+
80
+
81
+ class Redirect(urllib.request.HTTPRedirectHandler):
82
+ def __init__(self, allow_local_http):
83
+ self.allow_local_http = allow_local_http
84
+
85
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
86
+ checked_url(newurl, self.allow_local_http)
87
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
88
+
89
+
90
+ class Downloads:
91
+ def __init__(self, allow_local_http=False):
92
+ self.allow_local_http = allow_local_http
93
+ self.opener = urllib.request.build_opener(Redirect(allow_local_http))
94
+ self.bytes = 0
95
+
96
+ def fetch(self, url, path, *, limit, checksum=None, length=None):
97
+ checked_url(url, self.allow_local_http)
98
+ digest, size = hashlib.sha256(), 0
99
+ req = urllib.request.Request(
100
+ url,
101
+ headers={
102
+ "User-Agent": "steam-data-reader/" + READER_VERSION,
103
+ "Accept-Encoding": "identity",
104
+ },
105
+ )
106
+ with self.opener.open(req, timeout=60) as response, Path(path).open("wb") as output:
107
+ checked_url(response.url, self.allow_local_http)
108
+ while block := response.read(1024**2):
109
+ size += len(block)
110
+ self.bytes += len(block)
111
+ if size > limit:
112
+ raise ValueError("Download exceeds configured bound")
113
+ digest.update(block)
114
+ output.write(block)
115
+ if (length is not None and size != length) or (checksum and digest.hexdigest() != checksum):
116
+ raise ValueError("Download length/checksum mismatch")
117
+
118
+ def json(self, url, path, *, checksum=None):
119
+ self.fetch(url, path, limit=MAX_JSON, checksum=checksum)
120
+ return json.loads(Path(path).read_text(encoding="utf-8"))
121
+
122
+
123
+ def unpack(bundle, destination, manifest):
124
+ files = {f["path"]: f for t in manifest["tables"].values() for f in t["files"]}
125
+ expected = set(files) | {"data-manifest.json"}
126
+ with zipfile.ZipFile(bundle) as archive:
127
+ names = archive.namelist()
128
+ if len(names) != len(set(names)) or set(names) != expected or len(names) > 10000:
129
+ raise ValueError("Unexpected or duplicate archive entries")
130
+ total = sum(i.file_size for i in archive.infolist())
131
+ if total > MAX_EXPANDED or total != manifest["artifact"]["expanded_bytes"]:
132
+ raise ValueError("Expanded archive size mismatch")
133
+ for item in archive.infolist():
134
+ mode = item.external_attr >> 16
135
+ if (
136
+ item.flag_bits & 1
137
+ or stat.S_ISLNK(mode)
138
+ or (stat.S_IFMT(mode) not in (0, stat.S_IFREG))
139
+ ):
140
+ raise ValueError("Encrypted/link/special archive entry")
141
+ if item.filename == "data-manifest.json":
142
+ if item.file_size > MAX_JSON:
143
+ raise ValueError("Oversized internal manifest")
144
+ body = archive.read(item)
145
+ if sha256(body) != manifest["artifact"]["data_manifest_sha256"] or json.loads(
146
+ body
147
+ ) != {k: v for k, v in manifest.items() if k != "artifact"}:
148
+ raise ValueError("Internal/external manifest mismatch")
149
+ continue
150
+ spec = files[item.filename]
151
+ if item.file_size != spec["bytes"]:
152
+ raise ValueError("Expanded file size mismatch")
153
+ path = Path(destination) / item.filename
154
+ path.parent.mkdir(parents=True, exist_ok=True)
155
+ digest = hashlib.sha256()
156
+ with archive.open(item) as source, path.open("wb") as output:
157
+ while block := source.read(1024**2):
158
+ digest.update(block)
159
+ output.write(block)
160
+ if digest.hexdigest() != spec["sha256"]:
161
+ raise ValueError("Parquet checksum mismatch")
162
+
163
+
164
+ def install_release(path, extracted, manifest, *, baseline):
165
+ with connect(path, memory_limit="2048MB") as c:
166
+ c.execute("SET threads=1; SET preserve_insertion_order=false")
167
+ if baseline:
168
+ initialize(c)
169
+ c.execute("BEGIN")
170
+ try:
171
+ apply_tables(c, extracted, manifest)
172
+ set_info(c, manifest)
173
+ validate_state(c, manifest)
174
+ c.execute("COMMIT")
175
+ c.execute("CHECKPOINT")
176
+ except Exception:
177
+ c.execute("ROLLBACK")
178
+ raise
179
+
180
+
181
+ def sync(directory, *, source_url=None, allow_local_http=False):
182
+ directory = Path(directory)
183
+ directory.mkdir(parents=True, exist_ok=True)
184
+ result = SyncResult(None, None, None)
185
+ downloads = None
186
+ try:
187
+ with dataset_lock(directory, exclusive=True):
188
+ installed = None
189
+ rebuild_transport = False
190
+ database = directory / "catalog.duckdb"
191
+ if database.exists():
192
+ # Also check DuckDB's writer lock before staging against readers outside our API.
193
+ with connect(database) as c:
194
+ fields = [r[0] for r in c.execute("DESCRIBE steam.snapshot_info").fetchall()]
195
+ installed = dict(
196
+ zip(fields, c.execute("SELECT * FROM steam.snapshot_info").fetchone())
197
+ )
198
+ result.previous_version = result.installed_version = installed[
199
+ "dataset_version"
200
+ ]
201
+ # Private preview caches used non-spillable ART indexes.
202
+ # Rebuild through the verified replacement path so future
203
+ # increments cannot keep growing those obsolete indexes.
204
+ rebuild_transport = bool(
205
+ c.execute(
206
+ "SELECT 1 FROM duckdb_constraints() WHERE schema_name='transport' AND constraint_type='PRIMARY KEY' LIMIT 1"
207
+ ).fetchone()
208
+ )
209
+ settings = directory / "source.json"
210
+ source_url = source_url or (
211
+ json.loads(settings.read_text())["source_url"]
212
+ if settings.exists() else DEFAULT_SOURCE_URL
213
+ )
214
+ if not source_url:
215
+ raise ValueError("The configured snapshot source URL is empty")
216
+ checked_url(source_url, allow_local_http)
217
+ downloads = Downloads(allow_local_http)
218
+ with tempfile.TemporaryDirectory(dir=directory, prefix=".sync-") as temporary:
219
+ temporary = Path(temporary)
220
+ index = downloads.json(source_url, temporary / "index.json")
221
+ result.target_version = index["latest_version"]
222
+ for document in (index,):
223
+ try:
224
+ check_compatibility(document)
225
+ except ValueError:
226
+ raise ReaderUpgradeRequired(
227
+ f"Reader {READER_VERSION} does not support schema {document.get('schema_version')}, protocol {document.get('protocol_version')} or required reader {document.get('minimum_reader_version')}. Upgrade with: python -m pip install --upgrade --pre steam-data-reader",
228
+ installed_version=result.installed_version,
229
+ target_version=result.target_version,
230
+ ) from None
231
+ if index["dataset_id"] != DATASET_ID:
232
+ raise ValueError("Unexpected dataset")
233
+ refs = [index["baseline"]] + index["increments"]
234
+ if len(refs) > 366:
235
+ raise ValueError("Excessive increment chain")
236
+ manifests = []
237
+ for i, ref in enumerate(refs):
238
+ url = urllib.parse.urljoin(source_url, ref["url"])
239
+ m = downloads.json(
240
+ url, temporary / f"manifest-{i}.json", checksum=ref["sha256"]
241
+ )
242
+ try:
243
+ check_compatibility(m)
244
+ except ValueError:
245
+ raise ReaderUpgradeRequired(
246
+ f"Reader upgrade required for schema {m.get('schema_version')}; run python -m pip install --upgrade steam-data-reader",
247
+ installed_version=result.installed_version,
248
+ target_version=result.target_version,
249
+ ) from None
250
+ validate_manifest(m)
251
+ if any(
252
+ m[k] != index[k]
253
+ for k in ("dataset_id", "chain_id", "schema_version", "protocol_version")
254
+ ):
255
+ raise ValueError("Index/manifest identity mismatch")
256
+ if i == 0 and m["kind"] != "baseline":
257
+ raise ValueError("Chain does not start at baseline")
258
+ if i and (
259
+ m["kind"] != "increment"
260
+ or m["parent_version"] != manifests[-1][0]["dataset_version"]
261
+ or m["increment_sequence"] != i
262
+ or m["baseline_version"] != manifests[0][0]["dataset_version"]
263
+ ):
264
+ raise ValueError("Broken increment chain")
265
+ manifests.append((m, url))
266
+ if manifests[-1][0]["dataset_version"] != result.target_version:
267
+ raise ValueError("Index does not reach advertised version")
268
+ versions = [m["dataset_version"] for m, _ in manifests]
269
+ same_chain = (
270
+ installed
271
+ and not rebuild_transport
272
+ and installed["chain_id"] == index["chain_id"]
273
+ and installed["schema_version"] == index["schema_version"]
274
+ and installed["dataset_version"] in versions
275
+ )
276
+ start = versions.index(installed["dataset_version"]) + 1 if same_chain else 0
277
+ work = database if same_chain else temporary / "replacement.duckdb"
278
+ if same_chain:
279
+ wanted = manifests[start - 1][0]["logical_state_sha256"]
280
+ if installed["logical_state_sha256"] != wanted:
281
+ raise ValueError("Installed version has different state identity")
282
+ for i in range(start, len(manifests)):
283
+ m, url = manifests[i]
284
+ artifact = m["artifact"]
285
+ if (
286
+ type(artifact["bytes"]) is not int
287
+ or not 0 <= artifact["bytes"] <= MAX_BUNDLE
288
+ ):
289
+ raise ValueError("Bundle exceeds configured download limit")
290
+ required = artifact["bytes"] + artifact["expanded_bytes"] * 3
291
+ if shutil.disk_usage(directory).free < required:
292
+ raise OSError("Insufficient free disk space for staged snapshot")
293
+ bundle = temporary / "bundle.zip"
294
+ downloads.fetch(
295
+ urllib.parse.urljoin(url, artifact["url"]),
296
+ bundle,
297
+ limit=artifact["bytes"],
298
+ checksum=artifact["sha256"],
299
+ length=artifact["bytes"],
300
+ )
301
+ extracted = temporary / "extracted"
302
+ extracted.mkdir(exist_ok=True)
303
+ unpack(bundle, extracted, m)
304
+ install_release(work, extracted, m, baseline=i == 0)
305
+ shutil.rmtree(extracted)
306
+ bundle.unlink()
307
+ result.applied_increments += int(i > 0)
308
+ if same_chain:
309
+ result.installed_version = m["dataset_version"]
310
+ if not same_chain:
311
+ # Windows' durable flush requires a writable file handle.
312
+ with work.open("r+b") as stream:
313
+ os.fsync(stream.fileno())
314
+ os.replace(work, database)
315
+ result.baseline_replaced = True
316
+ result.installed_version = result.target_version
317
+ with staged_text(settings) as stream:
318
+ stream.write(canonical({"source_url": source_url}).decode())
319
+ result.installed_version = result.target_version
320
+ result.bytes_downloaded = downloads.bytes
321
+ result.complete = True
322
+ return result
323
+ except ReaderUpgradeRequired:
324
+ raise
325
+ except Exception as error: # noqa: BLE001 -- normalize download/database failures at public API boundary
326
+ if downloads is not None:
327
+ result.bytes_downloaded = downloads.bytes
328
+ raise SyncError(
329
+ f"Snapshot sync failed ({type(error).__name__}): {error}",
330
+ installed_version=result.installed_version,
331
+ target_version=result.target_version,
332
+ progress=result.to_dict(),
333
+ ) from None