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,8 @@
1
+ """Independent offline Steam catalog reader."""
2
+
3
+ from steam_data_schema import READER_VERSION as __version__
4
+
5
+ from .catalog import Catalog
6
+ from .sync import DEFAULT_SOURCE_URL, ReaderUpgradeRequired, SyncError, sync
7
+
8
+ __all__ = ["DEFAULT_SOURCE_URL", "Catalog", "ReaderUpgradeRequired", "SyncError", "__version__", "sync"]
@@ -0,0 +1,364 @@
1
+ """Pinned, read-only query API and the one shared serialization implementation."""
2
+
3
+ import csv
4
+ import hashlib
5
+ import math
6
+ import os
7
+ import tempfile
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+
11
+ from steam_data_schema import (
12
+ APP_FIELDS,
13
+ INFO_COLUMNS,
14
+ MINIMAL_FIELDS,
15
+ SPECIAL_FIELDS,
16
+ TABLES,
17
+ app_id,
18
+ canonical,
19
+ check_compatibility,
20
+ json_default,
21
+ normalize_text,
22
+ )
23
+
24
+ from .database import connect, decoded
25
+
26
+
27
+ @contextmanager
28
+ def dataset_lock(directory, *, exclusive=False):
29
+ path = Path(directory)
30
+ path.mkdir(parents=True, exist_ok=True)
31
+ with (path / ".catalog.lock").open("a+b") as f:
32
+ try:
33
+ if os.name == "nt":
34
+ import msvcrt
35
+
36
+ f.seek(0)
37
+ f.write(b"0")
38
+ f.flush()
39
+ f.seek(0)
40
+ msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK if exclusive else msvcrt.LK_NBRLCK, 1)
41
+ else:
42
+ import fcntl
43
+
44
+ fcntl.flock(f, (fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH) | fcntl.LOCK_NB)
45
+ except OSError:
46
+ raise OSError("Catalog is in use; close readers or another sync and retry") from None
47
+ try:
48
+ yield
49
+ finally:
50
+ if os.name == "nt":
51
+ f.seek(0)
52
+ msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
53
+ else:
54
+ fcntl.flock(f, fcntl.LOCK_UN)
55
+
56
+
57
+ @contextmanager
58
+ def staged_text(path):
59
+ path = Path(path)
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".export-")
62
+ try:
63
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as stream:
64
+ yield stream
65
+ stream.flush()
66
+ os.fsync(stream.fileno())
67
+ os.replace(temporary, path)
68
+ finally:
69
+ Path(temporary).unlink(missing_ok=True)
70
+
71
+
72
+ class Result:
73
+ def __init__(self, catalog, sql, parameters, columns, missing=None):
74
+ self.catalog, self.sql, self.parameters, self.columns = catalog, sql, parameters, columns
75
+ self.missing_appids = missing or []
76
+
77
+ def __iter__(self):
78
+ cursor = self.catalog.connection.cursor()
79
+ try:
80
+ cursor.execute(self.sql, self.parameters)
81
+ while batch := cursor.fetchmany(128):
82
+ rows = [decoded(row, self.columns) for row in batch]
83
+ special = set(self.columns) & set(SPECIAL_FIELDS)
84
+ if special:
85
+ by_id = {row["steam_appid"]: row for row in rows}
86
+ for field in special:
87
+ table = {
88
+ "relationships": "app_relationships",
89
+ "provenance": "field_provenance",
90
+ }.get(field, field)
91
+ for row in rows:
92
+ row[field] = []
93
+ # Avoid a whole-catalog list aggregation for a small requested set.
94
+ with self.catalog.connection.cursor() as nested:
95
+ nested.execute(
96
+ f"SELECT * FROM steam.{table} WHERE steam_appid=ANY(?::BIGINT[]) ORDER BY "
97
+ + ",".join(TABLES[table]["key"]),
98
+ [list(by_id)],
99
+ )
100
+ while values := nested.fetchmany(2000):
101
+ for value in values:
102
+ record = decoded(value, TABLES[table]["columns"])
103
+ by_id[record["steam_appid"]][field].append(record)
104
+ yield from rows
105
+ finally:
106
+ cursor.close()
107
+
108
+ def to_list(self):
109
+ return list(self)
110
+
111
+ def metadata(self):
112
+ return {k: self.catalog.info[k] for k in ("dataset_version", "schema_version")} | {
113
+ "missing_appids": self.missing_appids
114
+ }
115
+
116
+ def json_to(self, stream):
117
+ stream.write(canonical(self.metadata()).decode()[:-1] + ',"rows":[')
118
+ for i, row in enumerate(self):
119
+ stream.write(("," if i else "") + canonical(row).decode())
120
+ stream.write("]}\n")
121
+
122
+ def tsv_to(self, stream):
123
+ writer = csv.writer(stream, delimiter="\t", lineterminator="\n")
124
+ writer.writerow(self.columns)
125
+ count = 0
126
+ for row in self:
127
+ cells = []
128
+ for value in row.values():
129
+ if value is None:
130
+ cell = "\\N"
131
+ elif isinstance(value, (dict, list)):
132
+ cell = canonical(value).decode()
133
+ elif isinstance(value, bool):
134
+ cell = "true" if value else "false"
135
+ elif isinstance(value, str):
136
+ cell = value.replace("\\", "\\\\")
137
+ else:
138
+ cell = str(json_default(value)) if hasattr(value, "tzinfo") else str(value)
139
+ cells.append(cell)
140
+ writer.writerow(cells)
141
+ count += 1
142
+ return count
143
+
144
+ def write_json(self, path):
145
+ with staged_text(path) as stream:
146
+ self.json_to(stream)
147
+
148
+ def write_tsv(self, path):
149
+ path = Path(path)
150
+ with staged_text(path) as stream:
151
+ count = self.tsv_to(stream)
152
+ digest = hashlib.sha256()
153
+ with path.open("rb") as stream:
154
+ while block := stream.read(1024 * 1024):
155
+ digest.update(block)
156
+ meta = {
157
+ **self.metadata(),
158
+ "columns": self.columns,
159
+ "rows": count,
160
+ "bytes": path.stat().st_size,
161
+ "sha256": digest.hexdigest(),
162
+ "encoding": "utf-8",
163
+ "null": "\\N",
164
+ "scalar_string_escaping": "double backslashes before CSV quoting",
165
+ "delimiter": "\t",
166
+ }
167
+ with staged_text(str(path) + ".metadata.json") as stream:
168
+ stream.write(canonical(meta).decode() + "\n")
169
+
170
+
171
+ def sequence(values, validator=lambda v: v):
172
+ if isinstance(values, (str, bytes)):
173
+ raise TypeError("Expected a sequence, not a string")
174
+ return list(dict.fromkeys(validator(v) for v in values))
175
+
176
+
177
+ class Catalog:
178
+ @classmethod
179
+ def open(cls, directory):
180
+ return cls(directory)
181
+
182
+ def __init__(self, directory):
183
+ self.lock = dataset_lock(directory)
184
+ self.lock.__enter__()
185
+ self.connection = None
186
+ try:
187
+ self.connection = connect(Path(directory) / "catalog.duckdb", read_only=True)
188
+ row = self.connection.execute("SELECT * FROM steam.snapshot_info").fetchone()
189
+ self.info = decoded(row, INFO_COLUMNS)
190
+ try:
191
+ check_compatibility({**self.info, "protocol_version": 1})
192
+ except ValueError:
193
+ from .sync import ReaderUpgradeRequired
194
+
195
+ raise ReaderUpgradeRequired(
196
+ "Installed schema needs a newer reader",
197
+ installed_version=self.info["dataset_version"],
198
+ ) from None
199
+ except Exception:
200
+ self.close()
201
+ raise
202
+
203
+ def close(self):
204
+ if self.connection:
205
+ self.connection.close()
206
+ self.connection = None
207
+ if self.lock:
208
+ self.lock.__exit__(None, None, None)
209
+ self.lock = None
210
+
211
+ def __enter__(self):
212
+ return self
213
+
214
+ def __exit__(self, *args):
215
+ self.close()
216
+
217
+ def dataset_info(self):
218
+ return dict(self.info)
219
+
220
+ def list_genres(self):
221
+ return Result(
222
+ self,
223
+ "SELECT genre_id,label FROM steam.genres ORDER BY genre_id",
224
+ [],
225
+ TABLES["genres"]["columns"],
226
+ )
227
+
228
+ def _fields(self, fields, *, full=False):
229
+ fields = (
230
+ list(APP_FIELDS)
231
+ if full and fields is None
232
+ else MINIMAL_FIELDS
233
+ if fields is None
234
+ else sequence(fields)
235
+ )
236
+ if any(f not in APP_FIELDS for f in fields):
237
+ raise ValueError("Unknown app field")
238
+ names = ["steam_appid"] + [f for f in fields if f != "steam_appid"]
239
+ expr = []
240
+ for f in names:
241
+ if f in SPECIAL_FIELDS:
242
+ expr.append(f"NULL::JSON AS {f}")
243
+ else:
244
+ expr.append(f'a."{f}"')
245
+ return ",".join(expr), {f: APP_FIELDS[f] for f in names}
246
+
247
+ def get_apps(self, appids, *, fields=None):
248
+ ids = sequence(appids, app_id)
249
+ expr, columns = self._fields(fields, full=True)
250
+ # Scalar parameters let DuckDB push the ID filter into the view's
251
+ # component scans. An ANY(array) semi-join alone can retain a wide
252
+ # whole-catalog hash join even for a handful of requested apps.
253
+ selected = ",".join("?" for _ in ids) or "NULL"
254
+ found = {
255
+ r[0]
256
+ for r in self.connection.execute(
257
+ "SELECT steam_appid FROM steam.apps WHERE steam_appid=ANY(?::BIGINT[])", [ids]
258
+ ).fetchall()
259
+ }
260
+ return Result(
261
+ self,
262
+ f"SELECT {expr} FROM steam.apps a JOIN unnest(?::BIGINT[]) WITH ORDINALITY requested(steam_appid,position) USING(steam_appid) WHERE a.steam_appid IN ({selected}) ORDER BY requested.position",
263
+ [ids, *ids],
264
+ columns,
265
+ [i for i in ids if i not in found],
266
+ )
267
+
268
+ def list_apps(
269
+ self,
270
+ *,
271
+ appids=None,
272
+ name_prefix=None,
273
+ name_contains=None,
274
+ app_types=None,
275
+ genre_ids=None,
276
+ genre_match="any",
277
+ developer_names=None,
278
+ publisher_names=None,
279
+ min_reviews=None,
280
+ max_reviews=None,
281
+ min_positive_percentage=None,
282
+ max_positive_percentage=None,
283
+ fields=None,
284
+ order_by="steam_appid",
285
+ limit=None,
286
+ after_appid=None,
287
+ ):
288
+ expr, columns = self._fields(fields)
289
+ clauses, params = [], []
290
+
291
+ def add(sql, value):
292
+ clauses.append(sql)
293
+ params.append(value)
294
+
295
+ if genre_match not in ("any", "all") or order_by not in ("steam_appid", "name"):
296
+ raise ValueError("Invalid genre match/order")
297
+ if appids is not None:
298
+ ids = sequence(appids, app_id)
299
+ clauses.append("a.steam_appid IN (" + (",".join("?" for _ in ids) or "NULL") + ")")
300
+ params.extend(ids)
301
+ for value, function in ((name_prefix, "starts_with"), (name_contains, "contains")):
302
+ if value is not None:
303
+ add(
304
+ f"{function}((SELECT name_key FROM transport.app_search s WHERE s.steam_appid=a.steam_appid),?)",
305
+ normalize_text(value),
306
+ )
307
+ if app_types is not None:
308
+ values = sequence(
309
+ app_types, lambda v: v if isinstance(v, str) and v else normalize_text(v)
310
+ )
311
+ add("a.app_type=ANY(?::VARCHAR[])", values)
312
+ if genre_ids is not None:
313
+ values = sequence(
314
+ genre_ids, lambda v: v if isinstance(v, str) and v else normalize_text(v)
315
+ )
316
+ if not values and genre_match == "all":
317
+ raise ValueError("Empty all-genres filter")
318
+ add(
319
+ "(SELECT count(*) FROM steam.app_genres g WHERE g.steam_appid=a.steam_appid AND g.genre_id=ANY(?::VARCHAR[])) "
320
+ + (f"={len(values)}" if genre_match == "all" else ">0"),
321
+ values,
322
+ )
323
+ for values, role in ((developer_names, "developer"), (publisher_names, "publisher")):
324
+ if values is not None:
325
+ add(
326
+ f"list_has_any((SELECT {role}_keys FROM transport.app_search s WHERE s.steam_appid=a.steam_appid), ?::VARCHAR[])",
327
+ sequence(values, normalize_text),
328
+ )
329
+ for lower, upper, column, percentage in (
330
+ (min_reviews, max_reviews, "total_reviews", False),
331
+ (min_positive_percentage, max_positive_percentage, "positive_percentage", True),
332
+ ):
333
+ for value, op in ((lower, ">="), (upper, "<=")):
334
+ if value is not None:
335
+ if (
336
+ isinstance(value, bool)
337
+ or not isinstance(value, (int, float))
338
+ or not math.isfinite(value)
339
+ or value < 0
340
+ or (percentage and value > 100)
341
+ or (not percentage and type(value) is not int)
342
+ ):
343
+ raise ValueError("Invalid numeric bound")
344
+ add(f"a.{column}{op}?", value)
345
+ if lower is not None and upper is not None and lower > upper:
346
+ raise ValueError("Minimum exceeds maximum")
347
+ if after_appid is not None:
348
+ if order_by != "steam_appid":
349
+ raise ValueError("after_appid requires appID ordering")
350
+ add("a.steam_appid>?", app_id(after_appid))
351
+ if limit is not None and (type(limit) is not int or limit < 0):
352
+ raise ValueError("Limit must be a nonnegative integer")
353
+ sql = f"SELECT {expr} FROM steam.apps a" + (
354
+ " WHERE " + " AND ".join(clauses) if clauses else ""
355
+ )
356
+ sql += " ORDER BY " + (
357
+ "(SELECT name_key FROM transport.app_search s WHERE s.steam_appid=a.steam_appid),a.steam_appid"
358
+ if order_by == "name"
359
+ else "a.steam_appid"
360
+ )
361
+ if limit is not None:
362
+ sql += " LIMIT ?"
363
+ params.append(limit)
364
+ return Result(self, sql, params, columns)
@@ -0,0 +1,94 @@
1
+ """Thin CLI over the independent reader API; diagnostics never enter data output."""
2
+
3
+ import argparse
4
+ import sys
5
+ from importlib.metadata import metadata
6
+
7
+ import duckdb
8
+ from steam_data_schema import READER_VERSION, canonical
9
+
10
+ from .catalog import Catalog
11
+ from .sync import ReaderUpgradeRequired, SyncError, sync
12
+
13
+
14
+ def main(argv=None):
15
+ parser = argparse.ArgumentParser(prog="steam-snapshot")
16
+ parser.add_argument("--version", action="version", version=f"%(prog)s {READER_VERSION}")
17
+ commands = parser.add_subparsers(dest="command", required=True)
18
+ commands.add_parser("readme", help="Print the installed package's full README (offline)")
19
+ for name in ("sync", "info", "list-apps", "get-apps", "list-genres"):
20
+ p = commands.add_parser(name)
21
+ p.add_argument("--directory", required=True)
22
+ if name == "sync":
23
+ p.add_argument("--source", dest="source_url",
24
+ help="Override the saved source or built-in public R2 preview URL")
25
+ p.add_argument(
26
+ "--allow-local-http",
27
+ action="store_true",
28
+ help="Explicit loopback-only private test transport",
29
+ )
30
+ if name in ("list-apps", "get-apps", "list-genres"):
31
+ p.add_argument("--format", choices=("json", "tsv"), default="json")
32
+ p.add_argument("--output")
33
+ if name in ("list-apps", "get-apps"):
34
+ p.add_argument("--fields", type=lambda value: value.split(",") if value else [])
35
+ if name == "get-apps":
36
+ p.add_argument("appids", type=int, nargs="+")
37
+ if name == "list-apps":
38
+ for flag, dest, kind in (
39
+ ("appid", "appids", int),
40
+ ("app-type", "app_types", str),
41
+ ("genre-id", "genre_ids", str),
42
+ ("developer-name", "developer_names", str),
43
+ ("publisher-name", "publisher_names", str),
44
+ ):
45
+ p.add_argument("--" + flag, dest=dest, type=kind, action="append")
46
+ for flag in ("name-prefix", "name-contains"):
47
+ p.add_argument("--" + flag)
48
+ p.add_argument("--genre-match", choices=("any", "all"), default="any")
49
+ p.add_argument("--order-by", choices=("steam_appid", "name"), default="steam_appid")
50
+ for flag in ("min-reviews", "max-reviews", "limit", "after-appid"):
51
+ p.add_argument("--" + flag, type=int)
52
+ for flag in ("min-positive-percentage", "max-positive-percentage"):
53
+ p.add_argument("--" + flag, type=float)
54
+ args = vars(parser.parse_args(argv))
55
+ command, directory = args.pop("command"), args.pop("directory", None)
56
+ try:
57
+ if command == "readme":
58
+ print(metadata("steam-data-reader").get_payload())
59
+ return 0
60
+ if command == "sync":
61
+ print(canonical(sync(directory, **args).to_dict()).decode())
62
+ return 0
63
+ with Catalog.open(directory) as catalog:
64
+ if command == "info":
65
+ print(canonical(catalog.dataset_info()).decode())
66
+ return 0
67
+ format_, output = args.pop("format"), args.pop("output")
68
+ result = getattr(catalog, command.replace("-", "_"))(**args)
69
+ if output:
70
+ getattr(result, "write_" + format_)(output)
71
+ print(canonical({**result.metadata(), "output": output}).decode(), file=sys.stderr)
72
+ elif format_ == "json":
73
+ result.json_to(sys.stdout)
74
+ else:
75
+ result.tsv_to(sys.stdout)
76
+ print(canonical(result.metadata()).decode(), file=sys.stderr)
77
+ return 0
78
+ except ReaderUpgradeRequired as error:
79
+ print(str(error), file=sys.stderr)
80
+ return 3
81
+ except (ValueError, TypeError) as error:
82
+ print(str(error), file=sys.stderr)
83
+ return 2
84
+ except (SyncError, OSError, RuntimeError, duckdb.Error) as error:
85
+ print(str(error), file=sys.stderr)
86
+ return 1
87
+
88
+
89
+ def entrypoint():
90
+ raise SystemExit(main())
91
+
92
+
93
+ if __name__ == "__main__":
94
+ entrypoint()