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,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)
|
steam_data_reader/cli.py
ADDED
|
@@ -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()
|