Skip to content

Commit 332aa03

Browse files
committed
Add backup bundle command
1 parent 07d2cc2 commit 332aa03

3 files changed

Lines changed: 275 additions & 0 deletions

File tree

src/fakethirtyeight/backup.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""Create checksum manifests and compressed backups of preservation artifacts."""
2+
3+
from __future__ import annotations
4+
5+
import csv
6+
import hashlib
7+
import json
8+
import logging
9+
import tarfile
10+
from dataclasses import asdict, dataclass
11+
from datetime import UTC, datetime
12+
from pathlib import Path
13+
14+
log = logging.getLogger(__name__)
15+
16+
DEFAULT_BACKUP_SOURCES: tuple[str, ...] = (
17+
"data/*.csv",
18+
"data/*.json",
19+
"data/*.html",
20+
"data/articles",
21+
"data/images",
22+
"data/ai2html",
23+
"data/ai2html_renders",
24+
"data/embeds",
25+
"data/embed_renders",
26+
"data/dataset_bundles",
27+
"data/podcasts",
28+
"data/podcast_thumbnails",
29+
"web/static/data",
30+
)
31+
32+
EXCLUDED_DEFAULT_PATHS: tuple[str, ...] = (
33+
"data/index.csv",
34+
"data/shards",
35+
)
36+
37+
38+
@dataclass(frozen=True)
39+
class BackupFile:
40+
"""One file included in a backup manifest."""
41+
42+
path: str
43+
size: int
44+
mtime: str
45+
sha256: str
46+
47+
48+
@dataclass(frozen=True)
49+
class BackupResult:
50+
"""Paths and counts produced by a backup bundle run."""
51+
52+
archive_path: Path | None
53+
manifest_json_path: Path
54+
manifest_csv_path: Path
55+
file_count: int
56+
total_bytes: int
57+
58+
59+
def _has_glob(source: str) -> bool:
60+
return any(c in source for c in "*?[]")
61+
62+
63+
def _is_excluded(path: Path, excluded: set[Path]) -> bool:
64+
return any(path == item or item in path.parents for item in excluded)
65+
66+
67+
def _source_files(
68+
sources: tuple[str, ...] = DEFAULT_BACKUP_SOURCES,
69+
*,
70+
root: Path = Path(),
71+
excluded: tuple[str, ...] = EXCLUDED_DEFAULT_PATHS,
72+
) -> list[Path]:
73+
excluded_paths = {(root / item).resolve() for item in excluded}
74+
files: set[Path] = set()
75+
for source in sources:
76+
matches = root.glob(source) if _has_glob(source) else [(root / source)]
77+
for match in matches:
78+
if not match.exists():
79+
continue
80+
resolved = match.resolve()
81+
if _is_excluded(resolved, excluded_paths):
82+
continue
83+
if match.is_file():
84+
files.add(resolved)
85+
continue
86+
for path in match.rglob("*"):
87+
if path.is_file() and not _is_excluded(path.resolve(), excluded_paths):
88+
files.add(path.resolve())
89+
return sorted(files, key=lambda p: p.relative_to(root.resolve()).as_posix())
90+
91+
92+
def _sha256(path: Path) -> str:
93+
digest = hashlib.sha256()
94+
with path.open("rb") as fh:
95+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
96+
digest.update(chunk)
97+
return digest.hexdigest()
98+
99+
100+
def _manifest_rows(files: list[Path], *, root: Path = Path()) -> list[BackupFile]:
101+
rows: list[BackupFile] = []
102+
root_resolved = root.resolve()
103+
for path in files:
104+
stat = path.stat()
105+
rows.append(
106+
BackupFile(
107+
path=path.relative_to(root_resolved).as_posix(),
108+
size=stat.st_size,
109+
mtime=datetime.fromtimestamp(stat.st_mtime, UTC).isoformat(),
110+
sha256=_sha256(path),
111+
)
112+
)
113+
return rows
114+
115+
116+
def _write_manifest_json(
117+
path: Path, rows: list[BackupFile], *, created_at: str
118+
) -> None:
119+
payload = {
120+
"created_at": created_at,
121+
"file_count": len(rows),
122+
"total_bytes": sum(row.size for row in rows),
123+
"files": [asdict(row) for row in rows],
124+
}
125+
path.write_text(
126+
json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
127+
)
128+
129+
130+
def _write_manifest_csv(path: Path, rows: list[BackupFile]) -> None:
131+
with path.open("w", newline="", encoding="utf-8") as fh:
132+
writer = csv.DictWriter(fh, fieldnames=["path", "size", "mtime", "sha256"])
133+
writer.writeheader()
134+
for row in rows:
135+
writer.writerow(asdict(row))
136+
137+
138+
def create_backup_bundle(
139+
out_dir: Path,
140+
*,
141+
root: Path = Path(),
142+
name: str | None = None,
143+
sources: tuple[str, ...] = DEFAULT_BACKUP_SOURCES,
144+
dry_run: bool = False,
145+
) -> BackupResult:
146+
"""Create a manifest and optional ``.tar.gz`` archive in ``out_dir``."""
147+
148+
created_at = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
149+
bundle_name = name or f"fivethirtyeight-backup-{created_at}"
150+
out_dir.mkdir(parents=True, exist_ok=True)
151+
152+
files = _source_files(sources, root=root)
153+
rows = _manifest_rows(files, root=root)
154+
manifest_json_path = out_dir / f"{bundle_name}.manifest.json"
155+
manifest_csv_path = out_dir / f"{bundle_name}.manifest.csv"
156+
_write_manifest_json(manifest_json_path, rows, created_at=created_at)
157+
_write_manifest_csv(manifest_csv_path, rows)
158+
159+
archive_path = None
160+
if not dry_run:
161+
archive_path = out_dir / f"{bundle_name}.tar.gz"
162+
log.info("writing %s with %d files", archive_path, len(files))
163+
with tarfile.open(archive_path, "w:gz") as tf:
164+
tf.add(
165+
manifest_json_path, arcname=f"{bundle_name}/{manifest_json_path.name}"
166+
)
167+
tf.add(manifest_csv_path, arcname=f"{bundle_name}/{manifest_csv_path.name}")
168+
for path in files:
169+
arcname = f"{bundle_name}/{path.relative_to(root.resolve()).as_posix()}"
170+
tf.add(path, arcname=arcname)
171+
172+
return BackupResult(
173+
archive_path=archive_path,
174+
manifest_json_path=manifest_json_path,
175+
manifest_csv_path=manifest_csv_path,
176+
file_count=len(rows),
177+
total_bytes=sum(row.size for row in rows),
178+
)

src/fakethirtyeight/cli.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from fakethirtyeight import ai2html as ai2html_mod
1212
from fakethirtyeight import ai2html_review as ai2html_review_mod
1313
from fakethirtyeight import articles as articles_mod
14+
from fakethirtyeight import backup as backup_mod
1415
from fakethirtyeight import caption as caption_mod
1516
from fakethirtyeight import caption_review as caption_review_mod
1617
from fakethirtyeight import crawl as crawl_mod
@@ -1074,6 +1075,38 @@ def export(fmt: str, out_path: Path) -> None:
10741075
click.echo(f"wrote {count:,} rows to {out_path}")
10751076

10761077

1078+
@cli.command("backup-bundle")
1079+
@click.option(
1080+
"--out-dir",
1081+
type=click.Path(path_type=Path),
1082+
required=True,
1083+
help="Directory where the manifest and archive should be written.",
1084+
)
1085+
@click.option(
1086+
"--name",
1087+
default=None,
1088+
help="Base filename for outputs (default: timestamped fivethirtyeight-backup-*).",
1089+
)
1090+
@click.option(
1091+
"--dry-run",
1092+
is_flag=True,
1093+
help="Write manifests only; skip creating the compressed archive.",
1094+
)
1095+
def backup_bundle(out_dir: Path, name: str | None, dry_run: bool) -> None:
1096+
"""Create a manifest and compressed archive of preservation artifacts."""
1097+
result = backup_mod.create_backup_bundle(out_dir, name=name, dry_run=dry_run)
1098+
click.echo(
1099+
f"manifested {result.file_count:,} files "
1100+
f"({result.total_bytes / 1024 / 1024 / 1024:.2f} GiB)"
1101+
)
1102+
click.echo(f"json: {result.manifest_json_path}")
1103+
click.echo(f"csv: {result.manifest_csv_path}")
1104+
if result.archive_path:
1105+
click.echo(f"tar: {result.archive_path}")
1106+
else:
1107+
click.echo("tar: skipped (--dry-run)")
1108+
1109+
10771110
@cli.group()
10781111
def state() -> None:
10791112
"""Inspect/manage resume state."""

tests/test_backup.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
import csv
4+
import json
5+
import tarfile
6+
from pathlib import Path
7+
8+
from fakethirtyeight.backup import create_backup_bundle
9+
10+
11+
def test_create_backup_bundle_writes_manifests_and_archive(tmp_path: Path) -> None:
12+
root = tmp_path / "repo"
13+
out_dir = tmp_path / "nas"
14+
(root / "data/articles/2020").mkdir(parents=True)
15+
(root / "data/images/ab").mkdir(parents=True)
16+
(root / "data/shards").mkdir(parents=True)
17+
(root / "web/static/data").mkdir(parents=True)
18+
(root / "data/articles/2020/example.html.gz").write_bytes(b"article")
19+
(root / "data/images/ab/image.png").write_bytes(b"image")
20+
(root / "data/image_upload_log.csv").write_text(
21+
"id,status\n1,ok\n", encoding="utf-8"
22+
)
23+
(root / "web/static/data/graphics.json").write_text("[]\n", encoding="utf-8")
24+
(root / "data/index.csv").write_text("large regeneratable file\n", encoding="utf-8")
25+
(root / "data/shards/2008.csv").write_text(
26+
"regeneratable shard\n", encoding="utf-8"
27+
)
28+
29+
result = create_backup_bundle(out_dir, root=root, name="test-backup")
30+
31+
assert result.file_count == 4
32+
assert result.archive_path == out_dir / "test-backup.tar.gz"
33+
manifest = json.loads(result.manifest_json_path.read_text(encoding="utf-8"))
34+
manifest_paths = {row["path"] for row in manifest["files"]}
35+
assert manifest_paths == {
36+
"data/articles/2020/example.html.gz",
37+
"data/images/ab/image.png",
38+
"data/image_upload_log.csv",
39+
"web/static/data/graphics.json",
40+
}
41+
with result.manifest_csv_path.open(newline="", encoding="utf-8") as fh:
42+
assert {row["path"] for row in csv.DictReader(fh)} == manifest_paths
43+
assert all(row["sha256"] for row in manifest["files"])
44+
45+
assert result.archive_path is not None
46+
with tarfile.open(result.archive_path, "r:gz") as tf:
47+
names = set(tf.getnames())
48+
assert "test-backup/test-backup.manifest.json" in names
49+
assert "test-backup/data/images/ab/image.png" in names
50+
assert "test-backup/data/index.csv" not in names
51+
assert "test-backup/data/shards/2008.csv" not in names
52+
53+
54+
def test_create_backup_bundle_dry_run_skips_archive(tmp_path: Path) -> None:
55+
root = tmp_path / "repo"
56+
(root / "data").mkdir(parents=True)
57+
(root / "data/enriched.csv").write_text("id,title\n", encoding="utf-8")
58+
59+
result = create_backup_bundle(tmp_path / "nas", root=root, name="dry", dry_run=True)
60+
61+
assert result.archive_path is None
62+
assert result.manifest_json_path.exists()
63+
assert result.manifest_csv_path.exists()
64+
assert not (tmp_path / "nas/dry.tar.gz").exists()

0 commit comments

Comments
 (0)