file-analyzer-server 1.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.
@@ -0,0 +1,64 @@
1
+ """
2
+ ``file_analyzer.server`` -- the database-hosting server and its client.
3
+
4
+ This subpackage is the *hosting* half of the server split (the MCP/agent
5
+ transport half stays in ``mcp_server`` / :class:`FileAnalyzerMCPServer`). It
6
+ stands up a real, detachable, multi-instance server that hosts a repo-session's
7
+ databases under the ``PROJECT_ROOT-{session-token}-{db_name}`` convention in
8
+ SQLite or Postgres/MySQL, coordinates parallel instances through a shared
9
+ session catalog (with token reuse), runs periodic chunked rotating backups, can
10
+ surface the backend Postgres logs, and serves a token-authenticated HTTP control
11
+ plane that a :class:`ServerClient` connects to.
12
+
13
+ Everything here is import-safe on a bare interpreter: only the standard library
14
+ is imported at module load; the optional Postgres/MySQL drivers are imported
15
+ lazily by :class:`~file_analyzer.store.SqlStore` when a remote backend is
16
+ actually used.
17
+
18
+ References consulted for the implementation (best-practice grounding):
19
+
20
+ * SQLite online backup API (``Connection.backup(pages=...)``) --
21
+ https://docs.python.org/3/library/sqlite3.html#sqlite3.Connection.backup
22
+ * ``secrets`` for session tokens --
23
+ https://docs.python.org/3/library/secrets.html
24
+ * Cross-platform file locking (fcntl/msvcrt) --
25
+ https://dev.to/susumun/cross-platform-file-locking-in-python-fcntl-vs-msvcrt-from-scratch-19c5
26
+ * PostgreSQL logging / ``logging_collector`` / ``log_directory`` --
27
+ https://www.postgresql.org/docs/current/runtime-config-logging.html
28
+ * ``pg_dump`` custom-format backups --
29
+ https://www.postgresql.org/docs/current/app-pgdump.html
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from ..client import ServerClient, ServerError
35
+ from ..naming import sqlite_db_path, storage_key
36
+ from ..tokens import new_session_token, project_fingerprint, project_slug
37
+ from .backup import BackupManager
38
+ from .catalog import SessionCatalog, default_catalog_dir
39
+ from .daemon import pid_alive, spawn_detached, stop_pid
40
+ from .dbhost import DatabaseHost, HostedDatabase
41
+ from .pglog import PostgresLogTailer
42
+ from .sdk import FileAnalyzerServer
43
+ from .server import DatabaseServer
44
+
45
+ __all__ = [
46
+ "DatabaseServer",
47
+ "FileAnalyzerServer",
48
+ "ServerClient",
49
+ "ServerError",
50
+ "SessionCatalog",
51
+ "DatabaseHost",
52
+ "HostedDatabase",
53
+ "BackupManager",
54
+ "PostgresLogTailer",
55
+ "default_catalog_dir",
56
+ "storage_key",
57
+ "sqlite_db_path",
58
+ "new_session_token",
59
+ "project_fingerprint",
60
+ "project_slug",
61
+ "pid_alive",
62
+ "spawn_detached",
63
+ "stop_pid",
64
+ ]
@@ -0,0 +1,216 @@
1
+ """
2
+ ``python -m file_analyzer.server`` -- run and control the database-hosting server.
3
+
4
+ Subcommands::
5
+
6
+ start [root] [--detach] [options] # start a server (detached persists by PID)
7
+ run [root] [options] # foreground runner (what --detach launches)
8
+ stop [root] [--all] [--server-id] # stop this repo's server(s) by PID
9
+ status [root] # running servers + hosted databases
10
+ host [root] --db-name N --source P # host a database file under the session
11
+ databases [root] # list hosted databases (JSON)
12
+ backup [root] [--storage-key K] # run a backup now
13
+ logs [root] [--lines N] # tail the backend Postgres logs
14
+
15
+ ``start --detach`` launches a background process that outlives this shell and
16
+ writes a PID file; ``stop`` terminates it by that PID. Multiple ``start`` calls on
17
+ the same repository share one session token (no duplicate database copies).
18
+
19
+ The subcommand is optional: ``python -m file_analyzer.server /repo`` is treated as
20
+ ``python -m file_analyzer.server start /repo``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import sys
28
+ from typing import List, Optional
29
+
30
+ from .._version import __version__
31
+
32
+
33
+ def build_parser() -> argparse.ArgumentParser:
34
+ ap = argparse.ArgumentParser(
35
+ prog="python -m file_analyzer.server",
36
+ description="Host a repo-session's databases; serve them to clients.",
37
+ )
38
+ ap.add_argument(
39
+ "--version",
40
+ action="version",
41
+ version=f"file-analyzer {__version__}",
42
+ help="print the file-analyzer version and exit.",
43
+ )
44
+ sub = ap.add_subparsers(dest="command")
45
+
46
+ def _common(p: argparse.ArgumentParser, *, serving: bool) -> None:
47
+ p.add_argument("root", nargs="?", default=".", help="repository to host")
48
+ p.add_argument(
49
+ "--backend",
50
+ default="sqlite",
51
+ help="'sqlite' (live files, default) or a SQL URL "
52
+ "(postgresql://... / mysql://...) for the chunked content store.",
53
+ )
54
+ p.add_argument("--catalog-dir", default=None, help="shared catalog directory")
55
+ p.add_argument("--catalog-target", default=None, help="catalog SQL URL/path")
56
+ if serving:
57
+ p.add_argument("--host", default="127.0.0.1", help="bind host")
58
+ p.add_argument("--port", type=int, default=0, help="bind port (0=auto)")
59
+ p.add_argument("--token", default=None, help="explicit session token")
60
+ p.add_argument("--server-id", default=None, help="explicit server id")
61
+ p.add_argument(
62
+ "--chunk-bytes",
63
+ type=int,
64
+ default=8 * 1024 * 1024,
65
+ help="per-chunk size for the remote content store (default 8 MiB)",
66
+ )
67
+ p.add_argument(
68
+ "--backup-interval",
69
+ type=float,
70
+ default=900.0,
71
+ help="seconds between periodic backups (0 disables; default 900)",
72
+ )
73
+ p.add_argument(
74
+ "--backup-keep",
75
+ type=int,
76
+ default=5,
77
+ help="backup sets to keep per database before rotating (default 5)",
78
+ )
79
+ p.add_argument(
80
+ "--no-contain",
81
+ action="store_true",
82
+ help="skip the bare-metal containment guard (caller owns isolation)",
83
+ )
84
+
85
+ start = sub.add_parser("start", help="start a server (optionally detached)")
86
+ _common(start, serving=True)
87
+ start.add_argument(
88
+ "--detach",
89
+ action="store_true",
90
+ help="run in the background, detached with a PID, and return its endpoint",
91
+ )
92
+
93
+ run = sub.add_parser("run", help="foreground runner (used by --detach)")
94
+ _common(run, serving=True)
95
+
96
+ stop = sub.add_parser("stop", help="stop server(s) hosting a repository")
97
+ _common(stop, serving=False)
98
+ stop.add_argument("--all", action="store_true", help="stop all servers for repo")
99
+ stop.add_argument("--server-id", default=None, help="stop one server by id")
100
+
101
+ status = sub.add_parser("status", help="show running servers + hosted databases")
102
+ _common(status, serving=False)
103
+
104
+ host = sub.add_parser("host", help="host a database file under the session")
105
+ _common(host, serving=False)
106
+ host.add_argument("--db-name", required=True, help="logical database name")
107
+ host.add_argument("--source", required=True, help="path to the SQLite database")
108
+
109
+ dbs = sub.add_parser("databases", help="list hosted databases")
110
+ _common(dbs, serving=False)
111
+
112
+ backup = sub.add_parser("backup", help="run a backup now")
113
+ _common(backup, serving=False)
114
+ backup.add_argument("--storage-key", default=None, help="one database, else all")
115
+
116
+ logs = sub.add_parser("logs", help="tail the backend Postgres logs")
117
+ _common(logs, serving=False)
118
+ logs.add_argument("--lines", type=int, default=100, help="lines to show")
119
+
120
+ return ap
121
+
122
+
123
+ def _build_server(args: argparse.Namespace):
124
+ from .server import DatabaseServer
125
+
126
+ kwargs = dict(
127
+ root=args.root,
128
+ backend=args.backend,
129
+ catalog_dir=args.catalog_dir,
130
+ catalog_target=args.catalog_target,
131
+ )
132
+ for attr in (
133
+ "host",
134
+ "port",
135
+ "token",
136
+ "server_id",
137
+ "chunk_bytes",
138
+ "backup_interval",
139
+ "backup_keep",
140
+ ):
141
+ if hasattr(args, attr) and getattr(args, attr) is not None:
142
+ kwargs[attr] = getattr(args, attr)
143
+ return DatabaseServer(**kwargs)
144
+
145
+
146
+ def main(argv: Optional[List[str]] = None) -> int:
147
+ argv = list(sys.argv[1:] if argv is None else argv)
148
+ known = {
149
+ "start",
150
+ "run",
151
+ "stop",
152
+ "status",
153
+ "host",
154
+ "databases",
155
+ "backup",
156
+ "logs",
157
+ }
158
+ if argv and argv[0] not in known and argv[0] not in ("-h", "--help", "--version"):
159
+ argv = ["start", *argv]
160
+
161
+ args = build_parser().parse_args(argv)
162
+ if not args.command:
163
+ build_parser().print_help()
164
+ return 2
165
+
166
+ if args.command in ("start", "run"):
167
+ server = _build_server(args)
168
+ if args.command == "start" and getattr(args, "detach", False):
169
+ info = server.start_detached()
170
+ print(json.dumps(info, indent=2, default=str))
171
+ return 0
172
+ # Foreground run.
173
+ return server.serve(contained=not getattr(args, "no_contain", False))
174
+
175
+ if args.command == "stop":
176
+ server = _build_server(args)
177
+ result = server.stop(all_for_repo=args.all)
178
+ print(json.dumps(result, indent=2, default=str))
179
+ return 0
180
+
181
+ if args.command == "status":
182
+ server = _build_server(args)
183
+ print(json.dumps(server.status(), indent=2, default=str))
184
+ return 0
185
+
186
+ if args.command == "host":
187
+ server = _build_server(args)
188
+ rec = server.host_database(args.source, args.db_name)
189
+ print(json.dumps(rec, indent=2, default=str))
190
+ return 0
191
+
192
+ if args.command == "databases":
193
+ server = _build_server(args)
194
+ print(json.dumps(server.databases(), indent=2, default=str))
195
+ return 0
196
+
197
+ if args.command == "backup":
198
+ server = _build_server(args)
199
+ if args.storage_key:
200
+ out = server.backups.backup_database(args.storage_key)
201
+ else:
202
+ out = server.backups.backup_all()
203
+ print(json.dumps(out, indent=2, default=str))
204
+ return 0
205
+
206
+ if args.command == "logs":
207
+ server = _build_server(args)
208
+ print(json.dumps(server.postgres_logs(args.lines), indent=2, default=str))
209
+ return 0
210
+
211
+ build_parser().print_help()
212
+ return 2
213
+
214
+
215
+ if __name__ == "__main__":
216
+ raise SystemExit(main())
@@ -0,0 +1,351 @@
1
+ """
2
+ Periodic, chunked, rotating backups of hosted databases.
3
+
4
+ The server backs up what it hosts on a schedule, and does it in a way that keeps
5
+ individual backup artifacts bounded in size ("chunking so that db size will be
6
+ modulated") and old backups from piling up (rotation):
7
+
8
+ * **SQLite hosted databases** are copied with SQLite's *online backup API*
9
+ (``sqlite3.Connection.backup(dest, pages=...)``), which produces a consistent
10
+ snapshot without blocking writers and, by copying a bounded number of pages per
11
+ step, never holds the source locked for long. The snapshot is gzip-compressed
12
+ and then split into fixed-size ``.partNNN`` chunk files, with a JSON manifest
13
+ describing the set.
14
+ * **Remote (Postgres/MySQL) hosted databases** are reassembled from their content
15
+ chunks and backed up the same way; additionally, when the backend is Postgres
16
+ and ``pg_dump`` is on ``PATH``, a native ``pg_dump -Fc`` custom-format dump of
17
+ the whole cluster database is taken and chunked alongside.
18
+
19
+ :meth:`BackupManager.start_periodic` runs the whole thing on a background daemon
20
+ thread until stopped; the server owns one and stops it on shutdown.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import gzip
26
+ import hashlib
27
+ import json
28
+ import shutil
29
+ import sqlite3
30
+ import subprocess
31
+ import tempfile
32
+ import threading
33
+ import time
34
+ from pathlib import Path
35
+ from typing import Any, Dict, List, Optional
36
+
37
+ from ..tokens import PathLike
38
+ from .catalog import SessionCatalog
39
+ from .dbhost import DEFAULT_CHUNK_BYTES, DatabaseHost
40
+ from .locking import FileLock, atomic_write_text
41
+
42
+ #: SQLite pages copied per online-backup step (32 MiB at the 4 KiB default).
43
+ DEFAULT_BACKUP_PAGES = 8192
44
+ #: Default size of each backup part file: 16 MiB.
45
+ DEFAULT_PART_BYTES = 16 * 1024 * 1024
46
+ #: Default number of backup sets to keep per database before rotating out.
47
+ DEFAULT_KEEP = 5
48
+
49
+
50
+ def _timestamp() -> str:
51
+ return time.strftime("%Y%m%d-%H%M%S", time.gmtime())
52
+
53
+
54
+ class BackupManager:
55
+ """Creates, chunks, rotates and restores backups for a :class:`DatabaseHost`."""
56
+
57
+ def __init__(
58
+ self,
59
+ host: DatabaseHost,
60
+ *,
61
+ backup_dir: PathLike,
62
+ part_bytes: int = DEFAULT_PART_BYTES,
63
+ pages_per_step: int = DEFAULT_BACKUP_PAGES,
64
+ keep: int = DEFAULT_KEEP,
65
+ ) -> None:
66
+ self.host = host
67
+ self.backup_dir = Path(backup_dir)
68
+ self.backup_dir.mkdir(parents=True, exist_ok=True)
69
+ self.part_bytes = max(64 * 1024, int(part_bytes))
70
+ self.pages_per_step = max(64, int(pages_per_step))
71
+ self.keep = max(1, int(keep))
72
+ # Legacy shared lock path (kept for compatibility); per-database backups
73
+ # use a per-key lock so distinct databases back up genuinely concurrently.
74
+ self._lock_path = self.backup_dir / "backup.lock"
75
+ self._stop = threading.Event()
76
+ self._thread: Optional[threading.Thread] = None
77
+
78
+ def _key_lock_path(self, key: str) -> Path:
79
+ """Per-key lock: distinct storage keys never share a lock, so concurrent
80
+ backups of different databases do not serialize on one global lock. Each
81
+ key owns its own ``<backup_dir>/<key>/`` subtree (snapshot set + rotation),
82
+ so the lock is both sufficient and non-conflicting."""
83
+ key_dir = self.backup_dir / key
84
+ key_dir.mkdir(parents=True, exist_ok=True)
85
+ return key_dir / "backup.lock"
86
+
87
+ # -- snapshot -------------------------------------------------------
88
+ def _online_backup_sqlite(self, source_path: PathLike, dest_path: PathLike) -> None:
89
+ """Consistent hot copy of a SQLite file via the online backup API."""
90
+ src = sqlite3.connect(f"file:{Path(source_path).as_posix()}?mode=ro", uri=True)
91
+ try:
92
+ dst = sqlite3.connect(str(dest_path))
93
+ try:
94
+ src.backup(dst, pages=self.pages_per_step)
95
+ finally:
96
+ dst.close()
97
+ finally:
98
+ src.close()
99
+
100
+ def _chunk_file(self, plain_path: Path, key: str, ts: str) -> Dict[str, Any]:
101
+ """gzip ``plain_path`` and split it into bounded part files + manifest."""
102
+ set_dir = self.backup_dir / key / ts
103
+ set_dir.mkdir(parents=True, exist_ok=True)
104
+ parts: List[Dict[str, Any]] = []
105
+ sha = hashlib.sha256()
106
+ raw_size = 0
107
+ seq = 0
108
+ with open(plain_path, "rb") as fin:
109
+ # Stream through gzip in memory-bounded windows, emitting part files.
110
+ buf = bytearray()
111
+ compressor_path = set_dir / "_tmp.gz"
112
+ with gzip.open(compressor_path, "wb") as gz:
113
+ for block in iter(lambda: fin.read(1024 * 1024), b""):
114
+ raw_size += len(block)
115
+ sha.update(block)
116
+ gz.write(block)
117
+ # Now split the compressed file into parts.
118
+ with open(compressor_path, "rb") as gzf:
119
+ while True:
120
+ block = gzf.read(self.part_bytes)
121
+ if not block:
122
+ break
123
+ part_name = f"{key}.{ts}.gz.part{seq:04d}"
124
+ part_path = set_dir / part_name
125
+ part_path.write_bytes(block)
126
+ parts.append({"seq": seq, "name": part_name, "bytes": len(block)})
127
+ seq += 1
128
+ compressor_path.unlink()
129
+ manifest = {
130
+ "storage_key": key,
131
+ "timestamp": ts,
132
+ "raw_size_bytes": raw_size,
133
+ "raw_sha256": sha.hexdigest(),
134
+ "compression": "gzip",
135
+ "part_bytes": self.part_bytes,
136
+ "n_parts": len(parts),
137
+ "parts": parts,
138
+ "created_at": time.time(),
139
+ }
140
+ atomic_write_text(set_dir / "manifest.json", json.dumps(manifest, indent=2))
141
+ return manifest
142
+
143
+ def backup_database(self, storage_key_: str) -> Dict[str, Any]:
144
+ """Back up a single hosted database; return its manifest."""
145
+ rec = self.host.catalog.get_database(storage_key_)
146
+ if rec is None:
147
+ raise KeyError(f"no hosted database with key {storage_key_!r}")
148
+ ts = _timestamp()
149
+ with FileLock(self._key_lock_path(storage_key_), timeout=60.0):
150
+ with tempfile.TemporaryDirectory(prefix="fa-backup-") as tmpd:
151
+ snap = Path(tmpd) / "snapshot.db"
152
+ if rec.get("dialect") == "sqlite":
153
+ self._online_backup_sqlite(rec["location"], snap)
154
+ else:
155
+ # Reassemble remote chunks, then take a consistent copy.
156
+ materialized = self.host.materialize(storage_key_, snap)
157
+ snap = Path(materialized)
158
+ manifest = self._chunk_file(snap, storage_key_, ts)
159
+ self._rotate(storage_key_)
160
+ return manifest
161
+
162
+ def job_spec(self) -> Dict[str, Any]:
163
+ """A JSON-serializable description of this manager + its host + catalog.
164
+
165
+ A subprocess backup worker (driven by the Go backup pool) rebuilds an
166
+ identical :class:`SessionCatalog`, :class:`DatabaseHost` and
167
+ :class:`BackupManager` from this spec, so it can back up a subset of the
168
+ storage keys with exactly the same chunk/manifest/rotation logic -- which
169
+ is what keeps every produced backup restore-compatible regardless of
170
+ which process created it.
171
+ """
172
+ return {
173
+ "catalog_dir": str(self.host.catalog.dir),
174
+ "catalog_target": self.host.catalog.target,
175
+ "catalog_lock_timeout": self.host.catalog.lock_timeout,
176
+ "root": self.host.root,
177
+ "token": self.host.token,
178
+ "data_dir": str(self.host.data_dir),
179
+ "backend": self.host.backend,
180
+ "chunk_bytes": self.host.chunk_bytes,
181
+ "backup_dir": str(self.backup_dir),
182
+ "part_bytes": self.part_bytes,
183
+ "pages_per_step": self.pages_per_step,
184
+ "keep": self.keep,
185
+ }
186
+
187
+ @classmethod
188
+ def from_job_spec(cls, spec: Dict[str, Any]) -> "BackupManager":
189
+ """Rebuild a :class:`BackupManager` (with host + catalog) from a spec."""
190
+ catalog = SessionCatalog(
191
+ spec["catalog_dir"],
192
+ target=spec.get("catalog_target"),
193
+ lock_timeout=float(spec.get("catalog_lock_timeout", 30.0)),
194
+ )
195
+ host = DatabaseHost(
196
+ root=spec["root"],
197
+ token=spec["token"],
198
+ data_dir=spec["data_dir"],
199
+ catalog=catalog,
200
+ backend=spec.get("backend", "sqlite"),
201
+ chunk_bytes=int(spec.get("chunk_bytes", DEFAULT_CHUNK_BYTES)),
202
+ )
203
+ return cls(
204
+ host,
205
+ backup_dir=spec["backup_dir"],
206
+ part_bytes=int(spec.get("part_bytes", DEFAULT_PART_BYTES)),
207
+ pages_per_step=int(spec.get("pages_per_step", DEFAULT_BACKUP_PAGES)),
208
+ keep=int(spec.get("keep", DEFAULT_KEEP)),
209
+ )
210
+
211
+ def backup_all(
212
+ self, *, workers: Optional[int] = None, use_go: bool = True
213
+ ) -> List[Dict[str, Any]]:
214
+ """Back up every hosted database for this session, concurrently.
215
+
216
+ Distinct storage keys own disjoint on-disk subtrees and per-key locks, so
217
+ they back up in parallel with no contention. The work is fanned out by a
218
+ Go pool (goroutine pool spawning backup-worker subprocesses) when the Go
219
+ toolchain is present, and otherwise by a pure-Python thread pool -- both
220
+ call the same :meth:`backup_database`, so the produced backups are
221
+ identical either way. Returns one manifest (or ``{"error": ...}``) per key.
222
+ """
223
+ keys = [hosted.storage_key for hosted in self.host.list()]
224
+ if not keys:
225
+ return []
226
+ from .backup_pool import backup_keys
227
+
228
+ report = backup_keys(self, keys, workers=workers, use_go=use_go)
229
+ return report["results"]
230
+
231
+ # -- native postgres dump ------------------------------------------
232
+ def pg_dump(self, database_url: str) -> Optional[Dict[str, Any]]:
233
+ """Take a native ``pg_dump -Fc`` custom-format backup, then chunk it.
234
+
235
+ Returns the manifest, or ``None`` if ``pg_dump`` is not available. The
236
+ custom format (``-Fc``) is the production-recommended, restore-flexible
237
+ format; the dump streams to a file (never through memory).
238
+ """
239
+ if shutil.which("pg_dump") is None:
240
+ return None
241
+ ts = _timestamp()
242
+ with FileLock(self._key_lock_path("pg_cluster_dump"), timeout=120.0):
243
+ with tempfile.TemporaryDirectory(prefix="fa-pgdump-") as tmpd:
244
+ dump = Path(tmpd) / "cluster.dump"
245
+ with open(dump, "wb") as out:
246
+ proc = subprocess.run(
247
+ ["pg_dump", "--format=custom", "--no-owner", database_url],
248
+ stdout=out,
249
+ stderr=subprocess.PIPE,
250
+ )
251
+ if proc.returncode != 0:
252
+ raise RuntimeError(
253
+ "pg_dump failed: "
254
+ + proc.stderr.decode("utf-8", "replace").strip()
255
+ )
256
+ manifest = self._chunk_file(dump, "pg_cluster_dump", ts)
257
+ self._rotate("pg_cluster_dump")
258
+ return manifest
259
+
260
+ # -- rotation / listing / restore ----------------------------------
261
+ def _rotate(self, key: str) -> None:
262
+ key_dir = self.backup_dir / key
263
+ if not key_dir.is_dir():
264
+ return
265
+ sets = sorted(
266
+ (d for d in key_dir.iterdir() if d.is_dir()), key=lambda d: d.name
267
+ )
268
+ excess = len(sets) - self.keep
269
+ for old in sets[: max(0, excess)]:
270
+ shutil.rmtree(old, ignore_errors=True)
271
+
272
+ def list_backups(self, storage_key_: Optional[str] = None) -> List[Dict[str, Any]]:
273
+ out: List[Dict[str, Any]] = []
274
+ keys = (
275
+ [storage_key_]
276
+ if storage_key_
277
+ else [d.name for d in self.backup_dir.iterdir() if d.is_dir()]
278
+ )
279
+ for key in keys:
280
+ key_dir = self.backup_dir / key
281
+ if not key_dir.is_dir():
282
+ continue
283
+ for set_dir in sorted(key_dir.iterdir()):
284
+ man = set_dir / "manifest.json"
285
+ if man.is_file():
286
+ try:
287
+ out.append(json.loads(man.read_text(encoding="utf-8")))
288
+ except (OSError, ValueError):
289
+ continue
290
+ return out
291
+
292
+ def restore(self, storage_key_: str, timestamp: str, dest: PathLike) -> Path:
293
+ """Reassemble + decompress a backup set into ``dest`` (a SQLite file)."""
294
+ set_dir = self.backup_dir / storage_key_ / timestamp
295
+ man_path = set_dir / "manifest.json"
296
+ if not man_path.is_file():
297
+ raise FileNotFoundError(f"no backup manifest at {man_path}")
298
+ manifest = json.loads(man_path.read_text(encoding="utf-8"))
299
+ parts = sorted(manifest["parts"], key=lambda p: p["seq"])
300
+ dest = Path(dest)
301
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".gz") as tmp:
302
+ gz_path = Path(tmp.name)
303
+ for part in parts:
304
+ tmp.write((set_dir / part["name"]).read_bytes())
305
+ try:
306
+ with gzip.open(gz_path, "rb") as gz, open(dest, "wb") as fout:
307
+ shutil.copyfileobj(gz, fout)
308
+ finally:
309
+ gz_path.unlink()
310
+ expected = manifest.get("raw_sha256")
311
+ if expected:
312
+ h = hashlib.sha256()
313
+ with open(dest, "rb") as fh:
314
+ for block in iter(lambda: fh.read(1024 * 1024), b""):
315
+ h.update(block)
316
+ if h.hexdigest() != expected:
317
+ raise ValueError("restored file checksum mismatch")
318
+ return dest
319
+
320
+ # -- periodic loop --------------------------------------------------
321
+ def start_periodic(
322
+ self, interval: float, *, database_url: Optional[str] = None
323
+ ) -> "BackupManager":
324
+ """Run :meth:`backup_all` (and pg_dump, if applicable) every ``interval`` s."""
325
+ if self._thread is not None:
326
+ return self
327
+ self._stop.clear()
328
+
329
+ def _loop() -> None:
330
+ # Wait first, so startup is not immediately followed by a backup.
331
+ while not self._stop.wait(interval):
332
+ try:
333
+ self.backup_all()
334
+ if database_url and self.host.dialect == "postgresql":
335
+ try:
336
+ self.pg_dump(database_url)
337
+ except Exception:
338
+ pass
339
+ except Exception:
340
+ # Never let a backup failure kill the loop.
341
+ pass
342
+
343
+ self._thread = threading.Thread(target=_loop, name="fa-backup", daemon=True)
344
+ self._thread.start()
345
+ return self
346
+
347
+ def stop(self, timeout: float = 10.0) -> None:
348
+ self._stop.set()
349
+ if self._thread is not None:
350
+ self._thread.join(timeout=timeout)
351
+ self._thread = None