qlib-sync 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.
qlib_sync/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Safely install and update a local Qlib China A-share dataset."""
2
+
3
+ import os
4
+
5
+ # Recent mlflow (a Qlib dependency) logs an AI-agent hint on import; it is noise for our users.
6
+ os.environ.setdefault("MLFLOW_DISABLE_AGENT_HINT", "1")
7
+
8
+ from .api import health_check_cn, status_cn, update_cn
9
+ from .cn.update import UpdateError, UpdateResult
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ __all__ = ["UpdateError", "UpdateResult", "health_check_cn", "status_cn", "update_cn", "__version__"]
qlib_sync/api.py ADDED
@@ -0,0 +1,67 @@
1
+ """Python interface for notebooks and scripts.
2
+
3
+ >>> from qlib_sync import update_cn, status_cn, health_check_cn
4
+ >>> result = update_cn()
5
+ >>> print("\\n".join(result.summary_lines()))
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Iterable
12
+
13
+ from .cn import market
14
+ from .cn.update import DEFAULT_SOURCE_URL, UpdateOptions, UpdateResult, run_update
15
+
16
+
17
+ def _resolve(path: str | Path | None, default: Path) -> Path:
18
+ return Path(path).expanduser().resolve() if path is not None else default
19
+
20
+
21
+ def update_cn(
22
+ data_dir: str | Path | None = None,
23
+ *,
24
+ mirror: str | None = None,
25
+ protect: Iterable[str] = (),
26
+ keep_backup: bool = False,
27
+ force: bool = False,
28
+ protected_dir: str | Path | None = None,
29
+ source_url: str = DEFAULT_SOURCE_URL,
30
+ ) -> UpdateResult:
31
+ """Install or update the A-share dataset (default `~/.qlib/qlib_data/cn_data`).
32
+
33
+ Raises `qlib_sync.UpdateError` when the update cannot complete; the existing
34
+ dataset is left unchanged in that case.
35
+ """
36
+ options = UpdateOptions(
37
+ data_dir=_resolve(data_dir, market.DEFAULT_DATA_DIR),
38
+ protected_dir=_resolve(protected_dir, market.DEFAULT_PROTECTED_DIR),
39
+ source_url=source_url,
40
+ mirror=mirror,
41
+ protected=list(protect),
42
+ keep_backup=keep_backup,
43
+ force=force,
44
+ )
45
+ return run_update(options)
46
+
47
+
48
+ def status_cn(
49
+ data_dir: str | Path | None = None,
50
+ *,
51
+ offline: bool = False,
52
+ mirror: str | None = None,
53
+ timeout: int = 15,
54
+ ) -> dict:
55
+ """Local latest date, size, pools, backups, and (unless offline) the remote release."""
56
+ return market.collect_status(
57
+ _resolve(data_dir, market.DEFAULT_DATA_DIR),
58
+ market.DEFAULT_PROTECTED_DIR,
59
+ offline,
60
+ timeout,
61
+ mirror=mirror,
62
+ )
63
+
64
+
65
+ def health_check_cn(data_dir: str | Path | None = None, *, skip_qlib: bool = False) -> dict:
66
+ """Structure checks plus a real Qlib read; returns {"ok", "errors", "checks"}."""
67
+ return market.health_check(_resolve(data_dir, market.DEFAULT_DATA_DIR), market.DEFAULT_PROTECTED_DIR, skip_qlib)
qlib_sync/cli.py ADDED
@@ -0,0 +1,81 @@
1
+ """One command-line entry point for the local Qlib datasets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .cn.update import tilde
10
+
11
+ DEFAULT_QLIB_DATA_DIR = Path("~/.qlib/qlib_data").expanduser()
12
+
13
+ USAGE = """usage: qlib-sync <command> [options]
14
+
15
+ commands:
16
+ status Latest date and size of every local dataset
17
+ cn <subcommand> China A-share daily data: status, update, health-check, restore
18
+ mcp Serve the A-share data to AI assistants over MCP (stdio)
19
+
20
+ Run `qlib-sync cn --help` for details.
21
+ """
22
+
23
+
24
+ def read_last_line(path: Path) -> str | None:
25
+ if not path.is_file():
26
+ return None
27
+ lines = [line.strip() for line in path.read_text().splitlines() if line.strip()]
28
+ return lines[-1] if lines else None
29
+
30
+
31
+ def dataset_overview(base_dir: Path = DEFAULT_QLIB_DATA_DIR) -> list[dict[str, str]]:
32
+ rows = []
33
+ for dataset in sorted(path for path in base_dir.iterdir() if path.is_dir()) if base_dir.is_dir() else []:
34
+ # Qlib keeps planned trading days in `*_future.txt`; they are not data.
35
+ calendars = sorted(p for p in (dataset / "calendars").glob("*.txt") if not p.stem.endswith("_future"))
36
+ if not calendars:
37
+ continue
38
+ latest = max(filter(None, (read_last_line(path) for path in calendars)), default="missing")
39
+ freqs = ",".join(path.stem for path in calendars)
40
+ rows.append({"name": dataset.name, "freq": freqs, "latest": latest})
41
+ return rows
42
+
43
+
44
+ def print_overview(base_dir: Path = DEFAULT_QLIB_DATA_DIR) -> None:
45
+ rows = dataset_overview(base_dir)
46
+ if not rows:
47
+ print(f"no Qlib datasets found under {tilde(base_dir)}")
48
+ return
49
+ width = max(len(row["name"]) for row in rows)
50
+ print(f"Qlib datasets under {tilde(base_dir)}")
51
+ for row in rows:
52
+ print(f" {row['name']:<{width}} latest={row['latest']} freq={row['freq']}")
53
+
54
+
55
+ def run_module_main(module_name: str, prog: str, argv: list[str]) -> None:
56
+ # The dataset modules own their argparse parsers; hand them the remaining arguments.
57
+ sys.argv = [prog, *argv]
58
+ importlib.import_module(module_name).main()
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> None:
62
+ argv = list(sys.argv[1:] if argv is None else argv)
63
+ if not argv or argv[0] in ("-h", "--help"):
64
+ print(USAGE)
65
+ return
66
+ command, rest = argv[0], argv[1:]
67
+ if command == "status":
68
+ print_overview()
69
+ elif command == "cn":
70
+ run_module_main("qlib_sync.cn.market", "qlib-sync cn", rest)
71
+ elif command == "mcp":
72
+ from .mcp_server import main as mcp_main # pylint: disable=import-outside-toplevel
73
+
74
+ mcp_main(rest)
75
+ else:
76
+ print(USAGE, file=sys.stderr)
77
+ raise SystemExit(2)
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
@@ -0,0 +1 @@
1
+ """China A-share daily dataset operations."""
qlib_sync/cn/market.py ADDED
@@ -0,0 +1,381 @@
1
+ """Operate the local Qlib China market dataset through one safe command-line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import dataclasses
7
+ import datetime as dt
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from ..txn import exclusive_lock, replace_directory
13
+ from .update import (
14
+ BACKUP_PREFIX,
15
+ DEFAULT_SOURCE_URL,
16
+ MIRROR_ENV,
17
+ UpdateError,
18
+ UpdateOptions,
19
+ fetch_manifest,
20
+ lock_dir_for,
21
+ resolve_mirror,
22
+ run_update as run_package_update,
23
+ tilde,
24
+ )
25
+
26
+ DEFAULT_DATA_DIR = Path("~/.qlib/qlib_data/cn_data").expanduser()
27
+ DEFAULT_PROTECTED_DIR = Path("~/.qlib/qlib_data/custom_instruments/cn_data").expanduser()
28
+ DEFAULT_PROTECTED_FILES: tuple[str, ...] = ()
29
+
30
+
31
+ def log(message: str) -> None:
32
+ print(f"[INFO] {dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {message}", file=sys.stderr, flush=True)
33
+
34
+
35
+ def read_calendar(calendar_path: Path) -> list[str]:
36
+ if not calendar_path.is_file():
37
+ return []
38
+ return [line.strip() for line in calendar_path.read_text().splitlines() if line.strip()]
39
+
40
+
41
+ def read_latest_date(data_dir: Path) -> str | None:
42
+ calendar = read_calendar(data_dir / "calendars" / "day.txt")
43
+ return calendar[-1] if calendar else None
44
+
45
+
46
+ def instrument_stats(path: Path) -> dict[str, int | str]:
47
+ lines = [line for line in path.read_text().splitlines() if line.strip()]
48
+ symbols = {line.split()[0] for line in lines}
49
+ return {"name": path.stem, "records": len(lines), "unique_symbols": len(symbols)}
50
+
51
+
52
+ def list_instrument_stats(data_dir: Path) -> list[dict[str, int | str]]:
53
+ instruments_dir = data_dir / "instruments"
54
+ if not instruments_dir.is_dir():
55
+ return []
56
+ return [instrument_stats(path) for path in sorted(instruments_dir.glob("*.txt"))]
57
+
58
+
59
+ def list_backups(data_dir: Path) -> list[Path]:
60
+ return sorted(data_dir.parent.glob(f"{BACKUP_PREFIX}*"), reverse=True)
61
+
62
+
63
+ def directory_size(path: Path) -> int:
64
+ if not path.exists():
65
+ return 0
66
+ return sum(item.stat().st_size for item in path.rglob("*") if item.is_file())
67
+
68
+
69
+ def human_size(size: int) -> str:
70
+ value = float(size)
71
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
72
+ if value < 1024 or unit == "TiB":
73
+ return f"{value:.1f} {unit}"
74
+ value /= 1024
75
+ raise AssertionError("unreachable")
76
+
77
+
78
+ def related_dataset_status(data_dir: Path) -> list[dict[str, str | None]]:
79
+ output = []
80
+ for name in ("cn_etf_data", "cn_etf_data_hfq"):
81
+ path = data_dir.parent / name
82
+ if path.exists():
83
+ output.append({"name": name, "path": str(path), "latest_date": read_latest_date(path)})
84
+ return output
85
+
86
+
87
+ def collect_status(
88
+ data_dir: Path,
89
+ protected_dir: Path,
90
+ offline: bool,
91
+ timeout: int,
92
+ source_url: str = DEFAULT_SOURCE_URL,
93
+ mirror: str | None = None,
94
+ ) -> dict:
95
+ backups = list_backups(data_dir)
96
+ protected_files = sorted(path.name for path in protected_dir.glob("*.txt")) if protected_dir.is_dir() else []
97
+ result = {
98
+ "data_dir": str(data_dir),
99
+ "local_latest_date": read_latest_date(data_dir),
100
+ "size_bytes": directory_size(data_dir),
101
+ "instrument_pools": list_instrument_stats(data_dir),
102
+ "protected_dir": str(protected_dir),
103
+ "protected_files": protected_files,
104
+ "backups": [str(path) for path in backups],
105
+ "related_datasets": related_dataset_status(data_dir),
106
+ "remote_release": None,
107
+ "update_available": None,
108
+ }
109
+ if not offline:
110
+ manifest = fetch_manifest(source_url, timeout, resolve_mirror(mirror))
111
+ if manifest:
112
+ result["remote_release"] = dataclasses.asdict(manifest)
113
+ local = result["local_latest_date"]
114
+ result["update_available"] = local is None or manifest.trade_date > local
115
+ return result
116
+
117
+
118
+ def print_status(result: dict) -> None:
119
+ print(f"CN data: {tilde(result['data_dir'])}")
120
+ print(f"Local latest date: {result['local_latest_date'] or 'missing'}")
121
+ print(f"Data size: {human_size(result['size_bytes'])}")
122
+ remote = result.get("remote_release")
123
+ if remote:
124
+ size = f", {human_size(remote['size'])}" if remote.get("size") else ""
125
+ state = "update available" if result["update_available"] else "up to date"
126
+ print(f"Community release: trade date {remote['trade_date']}{size} ({state})")
127
+ print("Instrument pools:")
128
+ for pool in result["instrument_pools"]:
129
+ print(f" {pool['name']}: records={pool['records']} unique_symbols={pool['unique_symbols']}")
130
+ protected = ", ".join(result["protected_files"]) or "none"
131
+ print(f"Protected local pools: {protected} ({tilde(result['protected_dir'])})")
132
+ print(f"Rollback backups: {len(result['backups'])}")
133
+ for dataset in result["related_datasets"]:
134
+ print(f"Related dataset {dataset['name']}: latest={dataset['latest_date']} path={tilde(dataset['path'])}")
135
+
136
+
137
+ def run_update(args: argparse.Namespace) -> None:
138
+ protected = args.protect or list(DEFAULT_PROTECTED_FILES)
139
+ options = UpdateOptions(
140
+ data_dir=args.data_dir,
141
+ protected_dir=args.protected_dir,
142
+ protected=protected,
143
+ refresh_protected=args.refresh_protected,
144
+ keep_backup=args.keep_backup,
145
+ force=args.force,
146
+ source_url=args.source_url,
147
+ mirror=args.mirror,
148
+ )
149
+ log(f"start update: data_dir={tilde(args.data_dir)} protected={','.join(protected) or 'none'}")
150
+ try:
151
+ result = run_package_update(options)
152
+ except UpdateError as exc:
153
+ print(f"[ERROR] {exc}", file=sys.stderr)
154
+ raise SystemExit(1) from exc
155
+ if args.json:
156
+ print(json.dumps(dataclasses.asdict(result), indent=2))
157
+ else:
158
+ print("\n".join(result.summary_lines()))
159
+
160
+
161
+ def check_structure(data_dir: Path) -> tuple[list[str], list[str]]:
162
+ errors: list[str] = []
163
+ notes: list[str] = []
164
+ required = (data_dir / "calendars" / "day.txt", data_dir / "features", data_dir / "instruments" / "all.txt")
165
+ for path in required:
166
+ if not path.exists():
167
+ errors.append(f"missing required path: {path}")
168
+ calendar = read_calendar(data_dir / "calendars" / "day.txt")
169
+ if calendar:
170
+ if calendar != sorted(set(calendar)):
171
+ errors.append("daily calendar is not strictly increasing and unique")
172
+ try:
173
+ [dt.date.fromisoformat(value) for value in calendar]
174
+ except ValueError as exc:
175
+ errors.append(f"daily calendar contains an invalid date: {exc}")
176
+ notes.append(f"calendar_entries={len(calendar)} latest={calendar[-1]}")
177
+ elif not errors:
178
+ errors.append("daily calendar is empty")
179
+ pools = list_instrument_stats(data_dir)
180
+ if not pools:
181
+ errors.append("no instrument pools found")
182
+ else:
183
+ notes.append(f"instrument_pools={len(pools)}")
184
+ feature_files = list((data_dir / "features").glob("*/*.day.bin")) if (data_dir / "features").is_dir() else []
185
+ if not feature_files:
186
+ errors.append("no daily feature files found")
187
+ else:
188
+ notes.append(f"daily_feature_files={len(feature_files)}")
189
+ return errors, notes
190
+
191
+
192
+ def check_protected(data_dir: Path, protected_dir: Path) -> tuple[list[str], list[str]]:
193
+ errors: list[str] = []
194
+ notes: list[str] = []
195
+ if not protected_dir.is_dir():
196
+ return errors, notes
197
+ files = sorted(protected_dir.glob("*.txt"))
198
+ if not files:
199
+ return errors, notes
200
+ for protected_file in files:
201
+ active_file = data_dir / "instruments" / protected_file.name
202
+ if not active_file.is_file():
203
+ errors.append(f"protected pool is missing from active data: {protected_file.name}")
204
+ elif protected_file.read_bytes() != active_file.read_bytes():
205
+ errors.append(f"protected pool differs from active data: {protected_file.name}")
206
+ else:
207
+ notes.append(f"protected_pool_ok={protected_file.name}")
208
+ return errors, notes
209
+
210
+
211
+ def qlib_smoke_test(data_dir: Path) -> tuple[list[str], list[str]]:
212
+ # Keep Qlib imports lazy so status and backup inspection remain lightweight.
213
+ # pylint: disable=import-outside-toplevel
214
+ import qlib
215
+ from qlib.config import REG_CN
216
+ from qlib.data import D
217
+
218
+ errors: list[str] = []
219
+ notes: list[str] = []
220
+ try:
221
+ qlib.init(provider_uri=str(data_dir), region=REG_CN, logging_level="ERROR")
222
+ calendar = D.calendar(start_time="2020-01-01", end_time=None, freq="day")
223
+ if len(calendar) == 0:
224
+ errors.append("Qlib returned an empty daily calendar")
225
+ return errors, notes
226
+ notes.append(f"qlib_latest={calendar[-1].date().isoformat()}")
227
+ frame = D.features(
228
+ ["SH600000"],
229
+ ["$close", "$change"],
230
+ start_time=str(calendar[max(0, len(calendar) - 20)].date()),
231
+ end_time=str(calendar[-1].date()),
232
+ )
233
+ if frame.dropna(how="all").empty:
234
+ errors.append("Qlib feature read returned no close/change data for SH600000")
235
+ else:
236
+ notes.append(f"qlib_feature_rows={len(frame.dropna(how='all'))}")
237
+ except Exception as exc: # The command must report provider failures instead of hiding them.
238
+ errors.append(f"Qlib read failed: {exc}")
239
+ return errors, notes
240
+
241
+
242
+ def health_check(data_dir: Path, protected_dir: Path, skip_qlib: bool = False) -> dict:
243
+ errors, notes = check_structure(data_dir)
244
+ protected_errors, protected_notes = check_protected(data_dir, protected_dir)
245
+ errors.extend(protected_errors)
246
+ notes.extend(protected_notes)
247
+ if not skip_qlib and not errors:
248
+ qlib_errors, qlib_notes = qlib_smoke_test(data_dir)
249
+ errors.extend(qlib_errors)
250
+ notes.extend(qlib_notes)
251
+ return {"ok": not errors, "errors": errors, "checks": notes}
252
+
253
+
254
+ def run_health_check(args: argparse.Namespace) -> int:
255
+ log(f"start health-check: data_dir={tilde(args.data_dir)}")
256
+ report = health_check(args.data_dir, args.protected_dir, args.skip_qlib)
257
+ errors, notes = report["errors"], report["checks"]
258
+ for note in notes:
259
+ print(f"[OK] {note}")
260
+ for error in errors:
261
+ print(f"[ERROR] {error}", file=sys.stderr)
262
+ if errors:
263
+ log(f"end health-check: failed errors={len(errors)}")
264
+ return 1
265
+ log("end health-check: passed")
266
+ return 0
267
+
268
+
269
+ def resolve_backup(data_dir: Path, value: Path | None) -> Path:
270
+ if value is not None:
271
+ backup = value.expanduser().resolve()
272
+ if backup.parent != data_dir.parent.resolve() or not backup.name.startswith(BACKUP_PREFIX):
273
+ raise ValueError(f"backup must be a managed CN data backup under {data_dir.parent}")
274
+ if not backup.is_dir():
275
+ raise FileNotFoundError(f"backup not found: {backup}")
276
+ return backup
277
+ backups = list_backups(data_dir)
278
+ if not backups:
279
+ raise FileNotFoundError(f"no rollback backups found under {data_dir.parent}")
280
+ return backups[0]
281
+
282
+
283
+ def run_restore(args: argparse.Namespace) -> None:
284
+ backups = list_backups(args.data_dir)
285
+ if args.list:
286
+ for backup in backups:
287
+ print(f"{tilde(backup)}\tlatest={read_latest_date(backup)}\tsize={human_size(directory_size(backup))}")
288
+ return
289
+ if not args.yes:
290
+ raise RuntimeError("restore changes the active dataset; rerun with --yes after reviewing `restore --list`")
291
+ backup = resolve_backup(args.data_dir, args.backup)
292
+ backup_errors, _ = check_structure(backup)
293
+ if backup_errors:
294
+ raise RuntimeError(f"backup failed structural validation: {'; '.join(backup_errors)}")
295
+ stamp = dt.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
296
+ displaced = args.data_dir.parent / f"cn_data_before_restore_{stamp}"
297
+ log(f"start restore: backup={tilde(backup)} active={tilde(args.data_dir)}")
298
+ with exclusive_lock(lock_dir_for(args.data_dir)):
299
+ previous = replace_directory(args.data_dir, backup, displaced)
300
+ log(f"end restore: latest_date={read_latest_date(args.data_dir)} displaced_data={tilde(previous) if previous else 'none'}")
301
+
302
+
303
+ def add_data_args(parser: argparse.ArgumentParser) -> None:
304
+ parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA_DIR)
305
+ parser.add_argument("--protected-dir", type=Path, default=DEFAULT_PROTECTED_DIR)
306
+
307
+
308
+ def add_mirror_arg(parser: argparse.ArgumentParser) -> None:
309
+ parser.add_argument(
310
+ "--mirror",
311
+ help=f"Prefix-style GitHub proxy, e.g. https://ghfast.top (default: ${MIRROR_ENV})",
312
+ )
313
+
314
+
315
+ def add_subcommands(parser: argparse.ArgumentParser) -> None:
316
+ subparsers = parser.add_subparsers(dest="command", required=True)
317
+
318
+ status_parser = subparsers.add_parser("status", help="Show local data, pools, backups, and remote release metadata")
319
+ add_data_args(status_parser)
320
+ status_parser.add_argument("--offline", action="store_true", help="Do not read the remote release manifest")
321
+ status_parser.add_argument("--timeout", type=int, default=15)
322
+ status_parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
323
+ add_mirror_arg(status_parser)
324
+
325
+ update_parser = subparsers.add_parser(
326
+ "update", help="Install, or safely replace CN data with, the latest community package"
327
+ )
328
+ add_data_args(update_parser)
329
+ update_parser.add_argument(
330
+ "--protect", action="append", help="Protected instrument filename; repeat for multiple files"
331
+ )
332
+ update_parser.add_argument("--refresh-protected", action="store_true")
333
+ update_parser.add_argument("--keep-backup", action="store_true", help="Keep one rollback backup after a successful update")
334
+ update_parser.add_argument("--force", action="store_true", help="Replace even when package and local dates match")
335
+ update_parser.add_argument("--source-url", default=DEFAULT_SOURCE_URL, help="Community package URL")
336
+ update_parser.add_argument("--json", action="store_true", help="Print the update result as JSON")
337
+ add_mirror_arg(update_parser)
338
+
339
+ health_parser = subparsers.add_parser("health-check", help="Validate structure, protected pools, and Qlib reads")
340
+ add_data_args(health_parser)
341
+ health_parser.add_argument("--skip-qlib", action="store_true", help="Run filesystem checks only")
342
+
343
+ restore_parser = subparsers.add_parser("restore", help="List or restore managed CN data backups")
344
+ add_data_args(restore_parser)
345
+ restore_parser.add_argument("--list", action="store_true", help="List rollback backups without changing data")
346
+ restore_parser.add_argument("--backup", type=Path, help="Specific managed backup; defaults to the newest")
347
+ restore_parser.add_argument("--yes", action="store_true", help="Confirm replacement of the active data directory")
348
+
349
+
350
+
351
+ def get_parser() -> argparse.ArgumentParser:
352
+ parser = argparse.ArgumentParser(description=__doc__)
353
+ add_subcommands(parser)
354
+ return parser
355
+
356
+
357
+ def run(args: argparse.Namespace) -> None:
358
+ args.data_dir = args.data_dir.expanduser().resolve()
359
+ args.protected_dir = args.protected_dir.expanduser().resolve()
360
+ if args.command == "status":
361
+ result = collect_status(args.data_dir, args.protected_dir, args.offline, args.timeout, mirror=args.mirror)
362
+ if args.json:
363
+ print(json.dumps(result, indent=2, ensure_ascii=True))
364
+ else:
365
+ print_status(result)
366
+ elif args.command == "update":
367
+ run_update(args)
368
+ elif args.command == "health-check":
369
+ raise SystemExit(run_health_check(args))
370
+ elif args.command == "restore":
371
+ run_restore(args)
372
+ else:
373
+ raise AssertionError(f"unknown command: {args.command}")
374
+
375
+
376
+ def main() -> None:
377
+ run(get_parser().parse_args())
378
+
379
+
380
+ if __name__ == "__main__":
381
+ main()