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.
- steam_data_reader/__init__.py +8 -0
- steam_data_reader/catalog.py +364 -0
- steam_data_reader/cli.py +94 -0
- steam_data_reader/database.py +271 -0
- steam_data_reader/sync.py +333 -0
- steam_data_reader-1.0.0.dev1.dist-info/METADATA +472 -0
- steam_data_reader-1.0.0.dev1.dist-info/RECORD +10 -0
- steam_data_reader-1.0.0.dev1.dist-info/WHEEL +4 -0
- steam_data_reader-1.0.0.dev1.dist-info/entry_points.txt +2 -0
- steam_data_reader-1.0.0.dev1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|