django-sqlite-ops 0.1.0a1__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,542 @@
1
+ """Litestream CLI 호출: 원격·로컬 최대 TXID 조회와 restore (DESIGN §4-3).
2
+
3
+ 표준 라이브러리만 쓴다. Django 를 import 하지 않는다(DESIGN §4-1).
4
+
5
+ 공개 함수는 예외를 밖으로 던지지 않는다. 원격 조회는 ``RemoteError`` / ``RemoteEmpty`` /
6
+ ``RemoteTxid`` 중 하나를, 로컬 조회는 ``int | None`` 을, restore 는 ``RestoreResult`` 를
7
+ 돌려준다. 판정은 ``decide()`` 가 한다.
8
+
9
+ 빈 목록(rc 0, ``[]``)은 그대로 ``RemoteEmpty`` 다. 0.5.17 은 복제본 경로·prefix 오타와
10
+ "복제본 없음"을 같은 출력으로 돌려주므로 이 모듈은 둘을 구분하지 못한다. 그 빈 목록을 새 DB 의
11
+ 근거로 쓸지는 판정 정책이 정한다(``decide()`` 의 ``init_new``, D-13).
12
+
13
+ 출력 형식·동작은 Litestream 0.5.17 에서 실측했다(``tests/fixtures/litestream-0.5.17/``).
14
+ 검증하지 않은 버전이면 원격 조회와 restore 를 하지 않고 실패로 돌려준다.
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import signal
21
+ import stat
22
+ import subprocess
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ from .decide import Remote, RemoteEmpty, RemoteError, RemoteTxid
27
+
28
+ __all__ = [
29
+ "DEFAULT_LTX_TIMEOUT",
30
+ "DEFAULT_RESTORE_TIMEOUT",
31
+ "DEFAULT_VERSION_TIMEOUT",
32
+ "LtxWalRange",
33
+ "VERIFIED_VERSIONS",
34
+ "RestoreResult",
35
+ "check_version",
36
+ "config_databases",
37
+ "default_meta_path",
38
+ "local_max_ltx",
39
+ "ltx_wal_range",
40
+ "local_max_txid",
41
+ "parse_databases_json",
42
+ "parse_ltx_json",
43
+ "parse_version",
44
+ "remote_max_txid",
45
+ "restore",
46
+ "version",
47
+ ]
48
+
49
+ # 실측으로 출력 형식과 동작을 확인한 버전. 범위 밖이면 판정할 수 없으므로 거부한다.
50
+ VERIFIED_VERSIONS = frozenset({"0.5.17"})
51
+
52
+ # `litestream version` 은 네트워크를 쓰지 않는다. 바이너리가 멈췄을 때만 걸린다.
53
+ DEFAULT_VERSION_TIMEOUT = 10.0
54
+ # `ltx` 는 목록 조회 한 번이다(file:// 수 ms, 정상 S3 수 초). endpoint 에 http:// 가 빠지면
55
+ # 응답 없이 무한 대기하므로(실측) 부팅을 붙잡지 않도록 짧게 끊는다. 실패는 거부(exit 2)로 이어져
56
+ # 운영자가 원인을 보게 된다.
57
+ DEFAULT_LTX_TIMEOUT = 30.0
58
+ # restore 는 DB 크기에 비례한다. 수 GB 를 내려받을 수 있는 시간으로 넉넉히 잡고 호출자가 바꾼다.
59
+ DEFAULT_RESTORE_TIMEOUT = 600.0
60
+
61
+ # 타임아웃 뒤 프로세스 그룹을 죽이고 파이프가 닫히기를 기다리는 시간.
62
+ _KILL_GRACE = 5.0
63
+ # 사유 한 줄의 최대 길이.
64
+ _MAX_REASON = 300
65
+
66
+ # ltx.FormatFilename / TXID.String(): 16자리 소문자 16진수 (superfly/ltx v0.5.2).
67
+ _TXID_RE = re.compile(r"[0-9a-f]{16}")
68
+ _LTX_NAME_RE = re.compile(r"^([0-9a-f]{16})-([0-9a-f]{16})\.ltx$")
69
+
70
+ # LTX 헤더(superfly/ltx v0.5.2 ltx.go — Magic:20, HeaderSize:28, HeaderFlagNoChecksum:175,
71
+ # Header.MarshalBinary:283, IsValidPageSize:399, MaxPageSize:396). 모두 big-endian.
72
+ # [0:4] "LTX1" [4:8] flags [8:12] page size [16:24] min TXID [24:32] max TXID
73
+ # [48:56] WAL offset [56:64] WAL size [64:68] WAL salt-1 [68:72] WAL salt-2
74
+ _LTX_MAGIC = b"LTX1"
75
+ _LTX_HEADER_SIZE = 100
76
+ _LTX_FLAG_MASK = 1 << 1 # HeaderFlagNoChecksum 만 정의돼 있다
77
+ _LTX_PAGE_SIZES = frozenset(1 << n for n in range(9, 17)) # 512 … 65536
78
+ # Litestream(slog) 로그 줄. `restore -integrity-check` 는 이 줄을 JSON 앞 stdout 에 쓴다(실측).
79
+ _LOG_LINE_RE = re.compile(r"^time=\S+ level=[A-Z]+ ")
80
+ _VERSION_RE = re.compile(r"^v?(\d+\.\d+\.\d+)$")
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class RestoreResult:
85
+ """restore 결과. ``ok`` 이면 ``txid`` 는 복원된 최대 TXID 다."""
86
+
87
+ ok: bool
88
+ reason: str
89
+ txid: int | None = None
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class _Run:
94
+ rc: int
95
+ stdout: str
96
+ stderr: str
97
+
98
+
99
+ def _one_line(text: str) -> str:
100
+ line = " ".join(text.split())
101
+ if len(line) > _MAX_REASON:
102
+ line = line[: _MAX_REASON - 3] + "..."
103
+ return line
104
+
105
+
106
+ def _last_line(text: str) -> str:
107
+ lines = [ln for ln in text.splitlines() if ln.strip()]
108
+ return _one_line(lines[-1]) if lines else ""
109
+
110
+
111
+ def _kill(proc: subprocess.Popen) -> None:
112
+ # 자식이 만든 손자 프로세스가 파이프를 쥐고 있으면 communicate() 가 끝나지 않는다.
113
+ # 새 세션으로 띄웠으므로 그룹 전체를 죽인다.
114
+ try:
115
+ if hasattr(os, "killpg"):
116
+ os.killpg(proc.pid, signal.SIGKILL)
117
+ else: # pragma: no cover - Windows
118
+ proc.kill()
119
+ except OSError:
120
+ pass
121
+
122
+
123
+ def _run(argv: list[str], timeout: float, *, cwd: str | None = None) -> _Run | str:
124
+ """명령을 실행한다. 실행하지 못했거나 시간이 넘으면 사유 문자열을 돌려준다."""
125
+ try:
126
+ proc = subprocess.Popen(
127
+ argv,
128
+ cwd=cwd,
129
+ stdin=subprocess.DEVNULL,
130
+ stdout=subprocess.PIPE,
131
+ stderr=subprocess.PIPE,
132
+ start_new_session=True,
133
+ )
134
+ except OSError as exc:
135
+ return _one_line(f"cannot run {argv[0]}: {exc}")
136
+ except ValueError as exc:
137
+ # 경로에 NUL 등 OS 에 넘길 수 없는 값이 있다. 인자를 그대로 쓰지 않는다(제어 문자).
138
+ return _one_line(f"invalid litestream argument: {exc}")
139
+ try:
140
+ out, err = proc.communicate(timeout=timeout)
141
+ except subprocess.TimeoutExpired:
142
+ _kill(proc)
143
+ try:
144
+ proc.communicate(timeout=_KILL_GRACE)
145
+ except subprocess.TimeoutExpired:
146
+ pass
147
+ return f"{argv[0]} {argv[1]} timed out after {timeout:g}s"
148
+ return _Run(
149
+ rc=proc.returncode,
150
+ stdout=out.decode("utf-8", errors="replace"),
151
+ stderr=err.decode("utf-8", errors="replace"),
152
+ )
153
+
154
+
155
+ def _failure(cmd: str, run: _Run) -> str:
156
+ detail = _last_line(run.stderr) or _last_line(run.stdout) or "no output"
157
+ return _one_line(f"litestream {cmd} exited with {run.rc}: {detail}")
158
+
159
+
160
+ def _strip_log_lines(stdout: str) -> str:
161
+ lines = stdout.splitlines()
162
+ while lines and (not lines[0].strip() or _LOG_LINE_RE.match(lines[0])):
163
+ lines.pop(0)
164
+ return "\n".join(lines)
165
+
166
+
167
+ # --- 버전 ------------------------------------------------------------------------------
168
+
169
+
170
+ def parse_version(stdout: str) -> str | None:
171
+ """``litestream version`` 출력에서 ``X.Y.Z`` 를 읽는다. 형식이 다르면 ``None``."""
172
+ m = _VERSION_RE.match(stdout.strip())
173
+ return m.group(1) if m else None
174
+
175
+
176
+ def version(*, binary: str = "litestream", timeout: float = DEFAULT_VERSION_TIMEOUT) -> str | None:
177
+ """``litestream version`` 의 ``X.Y.Z``. 실행·파싱에 실패하면 ``None`` (기록용, 검증 아님)."""
178
+ run = _run([binary, "version"], timeout)
179
+ if isinstance(run, str) or run.rc != 0:
180
+ return None
181
+ return parse_version(run.stdout)
182
+
183
+
184
+ def check_version(
185
+ *, binary: str = "litestream", timeout: float = DEFAULT_VERSION_TIMEOUT
186
+ ) -> str | None:
187
+ """검증한 버전이면 ``None``, 아니면(읽지 못한 경우 포함) 사유 한 줄."""
188
+ run = _run([binary, "version"], timeout)
189
+ if isinstance(run, str):
190
+ return run
191
+ if run.rc != 0:
192
+ return _failure("version", run)
193
+ version = parse_version(run.stdout)
194
+ if version is None:
195
+ return f"cannot parse litestream version output: {_one_line(run.stdout)!r}"
196
+ if version not in VERIFIED_VERSIONS:
197
+ verified = ", ".join(sorted(VERIFIED_VERSIONS))
198
+ return f"unsupported litestream version {version}; verified: {verified}"
199
+ return None
200
+
201
+
202
+ # --- 원격 TXID ------------------------------------------------------------------------
203
+
204
+
205
+ def parse_ltx_json(stdout: str) -> Remote:
206
+ """``litestream ltx -level all -json`` 의 stdout 을 원격 결과로 바꾼다.
207
+
208
+ 빈 배열은 ``RemoteEmpty``, 항목이 있으면 모든 레벨의 ``max_txid`` 최대값을 ``RemoteTxid`` 로.
209
+ JSON 이 아니거나 스키마가 예상과 다르면 ``RemoteError``.
210
+ """
211
+ body = _strip_log_lines(stdout)
212
+ try:
213
+ data = json.loads(body)
214
+ except ValueError as exc:
215
+ return RemoteError(_one_line(f"cannot parse litestream ltx output as JSON: {exc}"))
216
+ if type(data) is not list:
217
+ return RemoteError(
218
+ f"unexpected litestream ltx output: expected a list, got {type(data).__name__}"
219
+ )
220
+ best: int | None = None
221
+ for i, item in enumerate(data):
222
+ if type(item) is not dict:
223
+ return RemoteError(f"unexpected litestream ltx output: item {i} is not an object")
224
+ level = item.get("level")
225
+ min_txid = item.get("min_txid")
226
+ max_txid = item.get("max_txid")
227
+ if type(level) is not int or not 0 <= level <= 9:
228
+ return RemoteError(f"unexpected litestream ltx output: item {i} has invalid level")
229
+ if type(min_txid) is not str or not _TXID_RE.fullmatch(min_txid):
230
+ return RemoteError(f"unexpected litestream ltx output: item {i} has invalid min_txid")
231
+ if type(max_txid) is not str or not _TXID_RE.fullmatch(max_txid):
232
+ return RemoteError(f"unexpected litestream ltx output: item {i} has invalid max_txid")
233
+ lo, hi = int(min_txid, 16), int(max_txid, 16)
234
+ if lo > hi:
235
+ return RemoteError(
236
+ f"unexpected litestream ltx output: item {i} has min_txid > max_txid"
237
+ )
238
+ best = hi if best is None else max(best, hi)
239
+ if best is None:
240
+ return RemoteEmpty()
241
+ return RemoteTxid(best)
242
+
243
+
244
+ def remote_max_txid(
245
+ db_path: str | os.PathLike[str],
246
+ *,
247
+ config: str | os.PathLike[str],
248
+ binary: str = "litestream",
249
+ timeout: float = DEFAULT_LTX_TIMEOUT,
250
+ version_timeout: float = DEFAULT_VERSION_TIMEOUT,
251
+ ) -> Remote:
252
+ """설정 파일의 복제본에서 모든 레벨의 최대 TXID 를 조회한다.
253
+
254
+ ``litestream ltx -level all -json`` 을 쓴다. 기본(``-level`` 생략)은 L0 만 나열해
255
+ L0 가 지워진 복제본을 빈 목록으로 오판한다(DESIGN §4-4).
256
+
257
+ 주의(0.5.17 실측): 복제본 경로가 없거나 비어 있어도 rc 0 과 ``[]`` 다. 둘은 출력으로
258
+ 구분되지 않으므로 둘 다 ``RemoteEmpty`` 다. 설정에 없는 DB·설정 파일 없음·접근 불가는 rc 1.
259
+ """
260
+ problem = check_version(binary=binary, timeout=version_timeout)
261
+ if problem is not None:
262
+ return RemoteError(problem)
263
+ argv = [
264
+ binary,
265
+ "ltx",
266
+ "-config",
267
+ os.fspath(config),
268
+ "-level",
269
+ "all",
270
+ "-json",
271
+ os.fspath(db_path),
272
+ ]
273
+ run = _run(argv, timeout)
274
+ if isinstance(run, str):
275
+ return RemoteError(run)
276
+ if run.rc != 0:
277
+ return RemoteError(_failure("ltx", run))
278
+ return parse_ltx_json(run.stdout)
279
+
280
+
281
+ # --- 설정의 DB 목록 -------------------------------------------------------------------
282
+
283
+
284
+ def parse_databases_json(stdout: str) -> list[str] | str:
285
+ """``litestream databases -json`` 의 stdout 에서 DB 경로 목록을 읽는다. 실패면 사유 문자열.
286
+
287
+ 0.5.17 출력은 ``[{"path": "<절대 경로>", "replica": "<종류>"}, ...]`` 다. Litestream 은
288
+ 경로를 ``filepath.Abs`` 로 바꿔 내보낸다: 상대 경로는 Litestream 의 작업 디렉터리 기준이 되고
289
+ ``..`` 는 문자열로 접힌다. 링크는 그대로 남는다(실측, ``tests/fixtures/litestream-0.5.17/``).
290
+ """
291
+ body = _strip_log_lines(stdout)
292
+ try:
293
+ data = json.loads(body)
294
+ except ValueError as exc:
295
+ return _one_line(f"cannot parse litestream databases output as JSON: {exc}")
296
+ if type(data) is not list:
297
+ return f"unexpected litestream databases output: expected a list, got {type(data).__name__}"
298
+ paths = []
299
+ for i, item in enumerate(data):
300
+ if type(item) is not dict:
301
+ return f"unexpected litestream databases output: item {i} is not an object"
302
+ path = item.get("path")
303
+ if type(path) is not str or not os.path.isabs(path) or "\0" in path:
304
+ return f"unexpected litestream databases output: item {i} has invalid path"
305
+ paths.append(path)
306
+ return paths
307
+
308
+
309
+ def config_databases(
310
+ config: str | os.PathLike[str],
311
+ *,
312
+ binary: str = "litestream",
313
+ timeout: float = DEFAULT_LTX_TIMEOUT,
314
+ version_timeout: float = DEFAULT_VERSION_TIMEOUT,
315
+ cwd: str | None = None,
316
+ ) -> list[str] | str:
317
+ """설정 파일의 DB 경로 목록(``litestream databases -json``). 실패면 사유 문자열.
318
+
319
+ YAML 을 직접 읽지 않는다. 경로 해석(환경 변수 확장·상대 경로·``..``)을 Litestream 이 하는
320
+ 그대로 본다. 상대 경로는 ``cwd`` 기준으로 풀린다. 설정 파일 없음·깨진 YAML 은 rc 1(실측).
321
+ """
322
+ problem = check_version(binary=binary, timeout=version_timeout)
323
+ if problem is not None:
324
+ return problem
325
+ run = _run([binary, "databases", "-config", os.fspath(config), "-json"], timeout, cwd=cwd)
326
+ if isinstance(run, str):
327
+ return run
328
+ if run.rc != 0:
329
+ return _failure("databases", run)
330
+ return parse_databases_json(run.stdout)
331
+
332
+
333
+ # --- 로컬 TXID ------------------------------------------------------------------------
334
+
335
+
336
+ def default_meta_path(db_path: str | os.PathLike[str]) -> Path:
337
+ """Litestream 기본 메타 디렉터리 ``<dir>/.<name>-litestream`` (설정의 meta-path 가 없을 때)."""
338
+ db = Path(db_path)
339
+ return db.parent / f".{db.name}-litestream"
340
+
341
+
342
+ def _read_ltx_header(path: Path) -> bytes | None:
343
+ """검증한 LTX 헤더 100바이트. 정규 파일·헤더 형식이 아니면 ``None``.
344
+
345
+ LTX 파일 자체가 심볼릭 링크이면 거부한다. Litestream 은 ``ltx/0`` 안에 파일 링크를 만들지
346
+ 않으므로 예상 밖 상태다. 상위 디렉터리(메타 디렉터리·``ltx``·``ltx/0``)의 링크는 허용하고
347
+ 따라간다(볼륨 연결 등). FIFO·장치 파일에서 멈추지 않도록 열기 전후로 정규 파일인지 본다.
348
+ """
349
+ try:
350
+ if not stat.S_ISREG(os.lstat(path).st_mode):
351
+ return None
352
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
353
+ fd = os.open(path, flags)
354
+ try:
355
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
356
+ return None
357
+ header = b""
358
+ while len(header) < _LTX_HEADER_SIZE:
359
+ chunk = os.read(fd, _LTX_HEADER_SIZE - len(header))
360
+ if not chunk:
361
+ break
362
+ header += chunk
363
+ finally:
364
+ os.close(fd)
365
+ except (OSError, ValueError):
366
+ return None
367
+ if len(header) < _LTX_HEADER_SIZE or header[:4] != _LTX_MAGIC:
368
+ return None
369
+ if int.from_bytes(header[4:8], "big") & ~_LTX_FLAG_MASK:
370
+ return None
371
+ if int.from_bytes(header[8:12], "big") not in _LTX_PAGE_SIZES:
372
+ return None
373
+ return header
374
+
375
+
376
+ def _read_ltx_range(path: Path) -> tuple[int, int] | None:
377
+ """LTX 파일 헤더의 (min TXID, max TXID). 정규 파일·헤더 형식이 아니면 ``None``."""
378
+ header = _read_ltx_header(path)
379
+ if header is None:
380
+ return None
381
+ return int.from_bytes(header[16:24], "big"), int.from_bytes(header[24:32], "big")
382
+
383
+
384
+ @dataclass(frozen=True, slots=True)
385
+ class LtxWalRange:
386
+ """L0 파일이 원래 WAL 의 어디까지를 담았는가(superfly/ltx v0.5.2 ``Header``).
387
+
388
+ ``offset + size`` 가 그 L0 가 읽은 WAL 의 끝(바이트)이다. Litestream 0.5.17 도 다음 동기화를
389
+ 이 위치와 salt 에서 이어 간다(``db.go`` ``verifyWithExecutor``: ``info.offset =
390
+ WALOffset + WALSize``). 저널 모드나 압축 파일은 0 이다.
391
+ """
392
+
393
+ page_size: int
394
+ offset: int
395
+ size: int
396
+ salt1: int
397
+ salt2: int
398
+
399
+ @property
400
+ def end(self) -> int:
401
+ return self.offset + self.size
402
+
403
+
404
+ def ltx_wal_range(path: str | os.PathLike[str]) -> LtxWalRange | None:
405
+ """LTX 헤더의 WAL 위치 필드. 읽을 수 없거나 WAL 정보가 없으면(``offset`` 0) ``None``.
406
+
407
+ 필드(``ltx.go`` ``Header.MarshalBinary`` 293–296, big-endian): ``[48:56]`` WALOffset(int64),
408
+ ``[56:64]`` WALSize(int64), ``[64:68]`` WALSalt1, ``[68:72]`` WALSalt2. ``Header.Validate``
409
+ (228–241)처럼 음수 금지, salt 가 있으면 offset 이 0 이 아니어야 한다.
410
+ """
411
+ header = _read_ltx_header(Path(path))
412
+ if header is None:
413
+ return None
414
+ offset = int.from_bytes(header[48:56], "big", signed=True)
415
+ size = int.from_bytes(header[56:64], "big", signed=True)
416
+ salt1 = int.from_bytes(header[64:68], "big")
417
+ salt2 = int.from_bytes(header[68:72], "big")
418
+ if offset <= 0 or size < 0:
419
+ return None
420
+ return LtxWalRange(int.from_bytes(header[8:12], "big"), offset, size, salt1, salt2)
421
+
422
+
423
+ def local_max_ltx(
424
+ db_path: str | os.PathLike[str], *, meta_path: str | os.PathLike[str] | None = None
425
+ ) -> tuple[int, Path] | None:
426
+ """``local_max_txid()`` 와 같은 후보 하나를 같은 규칙으로 검증해 ``(max TXID, 파일 경로)``.
427
+
428
+ 헬스(#7)가 그 파일의 mtime 을 DB 변경 시각과 비교하려고 쓴다. 믿을 수 없으면 ``None``.
429
+ """
430
+ try:
431
+ meta = Path(meta_path) if meta_path is not None else default_meta_path(db_path)
432
+ l0 = meta / "ltx" / "0"
433
+ names = os.listdir(l0)
434
+ except (OSError, ValueError):
435
+ return None
436
+ best: tuple[int, int, str] | None = None
437
+ for name in sorted(names):
438
+ m = _LTX_NAME_RE.match(name)
439
+ if m is None:
440
+ continue
441
+ lo, hi = int(m.group(1), 16), int(m.group(2), 16)
442
+ if best is None or hi > best[1]:
443
+ best = (lo, hi, name)
444
+ if best is None:
445
+ return None
446
+ lo, hi, name = best
447
+ if not 1 <= lo <= hi:
448
+ return None
449
+ if _read_ltx_range(l0 / name) != (lo, hi):
450
+ return None
451
+ return hi, l0 / name
452
+
453
+
454
+ def local_max_txid(
455
+ db_path: str | os.PathLike[str], *, meta_path: str | os.PathLike[str] | None = None
456
+ ) -> int | None:
457
+ """로컬 메타의 L0 에서 최신 LTX 파일의 max TXID 를 읽는다. 믿을 수 없으면 ``None``.
458
+
459
+ 후보 고르기는 Litestream 0.5.17 ``DB.MaxLTX()`` 와 같다: ``<meta>/ltx/0/`` 의 파일 이름
460
+ ``<min>-<max>.ltx`` 중 ``max`` 가 가장 큰 것(같으면 이름순 첫 번째). L0 보존 정리는 가장 새
461
+ L0 파일을 지우지 않으므로 업로드·압축·종료 뒤에도 남는다(db.go ``EnforceL0RetentionByTime``,
462
+ 실측).
463
+
464
+ 고른 후보 **하나**를 검증한다. 정규 파일(파일 자체가 링크면 거부, 상위 디렉터리 링크는
465
+ 허용), 읽기 가능, 1 ≤ min ≤ max, LTX 헤더의 매직·flags·page size 가 유효하고 헤더의
466
+ min/max 가 이름과 같아야 한다. 하나라도 어긋나면 낮은 후보로 내려가지 않고 ``None`` 이다.
467
+ 체크섬·페이지 전체 검증(Litestream ``DB.Pos()`` 의 ``Decoder.Verify()``)은 하지 않는다.
468
+ """
469
+ found = local_max_ltx(db_path, meta_path=meta_path)
470
+ return None if found is None else found[0]
471
+
472
+
473
+ # --- restore --------------------------------------------------------------------------
474
+
475
+
476
+ def _parse_restore_json(stdout: str) -> int | str:
477
+ """restore ``-json`` 요약에서 txid 를 읽는다. 실패면 사유 문자열."""
478
+ body = _strip_log_lines(stdout)
479
+ try:
480
+ data = json.loads(body)
481
+ except ValueError as exc:
482
+ return _one_line(f"cannot parse litestream restore output as JSON: {exc}")
483
+ if type(data) is not dict:
484
+ return "unexpected litestream restore output: expected an object"
485
+ txid = data.get("txid")
486
+ if type(txid) is not str or not _TXID_RE.fullmatch(txid):
487
+ return "unexpected litestream restore output: invalid txid"
488
+ return int(txid, 16)
489
+
490
+
491
+ def restore(
492
+ db_path: str | os.PathLike[str],
493
+ output: str | os.PathLike[str],
494
+ *,
495
+ config: str | os.PathLike[str],
496
+ binary: str = "litestream",
497
+ timeout: float = DEFAULT_RESTORE_TIMEOUT,
498
+ version_timeout: float = DEFAULT_VERSION_TIMEOUT,
499
+ integrity_check: str = "quick",
500
+ ) -> RestoreResult:
501
+ """설정 파일의 복제본을 ``output`` 경로로 복원하고 성공을 확인한다.
502
+
503
+ ``output`` 은 아직 없는 경로여야 한다(``-force`` 를 쓰지 않는다). 실제 DB 교체·격리는
504
+ 호출자 몫이다. ``-if-db-not-exists`` 는 쓰지 않는다: 0바이트 DB 파일이 있으면 rc 0 으로
505
+ 복원을 건너뛴다(0.5.17 실측, DESIGN §4-1). 실패·타임아웃 때 ``output`` 에 남은 파일은
506
+ 지우지 않는다.
507
+ """
508
+ out = os.fspath(output)
509
+ if os.path.lexists(out):
510
+ return RestoreResult(False, f"restore output already exists: {out}")
511
+ if integrity_check not in ("none", "quick", "full"):
512
+ return RestoreResult(False, f"invalid integrity_check {integrity_check!r}")
513
+ problem = check_version(binary=binary, timeout=version_timeout)
514
+ if problem is not None:
515
+ return RestoreResult(False, problem)
516
+ argv = [
517
+ binary,
518
+ "restore",
519
+ "-config",
520
+ os.fspath(config),
521
+ "-json",
522
+ "-integrity-check",
523
+ integrity_check,
524
+ "-o",
525
+ out,
526
+ os.fspath(db_path),
527
+ ]
528
+ run = _run(argv, timeout)
529
+ if isinstance(run, str):
530
+ return RestoreResult(False, run)
531
+ if run.rc != 0:
532
+ return RestoreResult(False, _failure("restore", run))
533
+ txid = _parse_restore_json(run.stdout)
534
+ if isinstance(txid, str):
535
+ return RestoreResult(False, txid)
536
+ try:
537
+ size = os.path.getsize(out)
538
+ except OSError as exc:
539
+ return RestoreResult(False, _one_line(f"restored file is missing: {exc}"))
540
+ if size == 0:
541
+ return RestoreResult(False, f"restored file is empty: {out}")
542
+ return RestoreResult(True, f"restored txid {txid} to {out}", txid)