pettrace-ml 0.2.5__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.
- pettrace/__init__.py +12 -0
- pettrace/_build.json +8 -0
- pettrace/artifact_upload.py +61 -0
- pettrace/cli.py +47 -0
- pettrace/data/__init__.py +1 -0
- pettrace/data/acquisition.py +117 -0
- pettrace/data/benchmark.py +71 -0
- pettrace/data/checkpoints.py +259 -0
- pettrace/data/curation.py +130 -0
- pettrace/data/lookup.py +133 -0
- pettrace/data/pilot.py +359 -0
- pettrace/data/preparation.py +489 -0
- pettrace/data/review.py +375 -0
- pettrace/data/screening.py +138 -0
- pettrace/data/screening_gpu.py +259 -0
- pettrace/data/source_capacity_v1.json +18 -0
- pettrace/data/sources.py +208 -0
- pettrace/data/verification.py +72 -0
- pettrace/data/versions.py +153 -0
- pettrace/data/volume.py +274 -0
- pettrace/data/workflow.py +157 -0
- pettrace/evaluation/__init__.py +1 -0
- pettrace/evaluation/core.py +217 -0
- pettrace/evaluation/figures.py +368 -0
- pettrace/evaluation/metrics.py +98 -0
- pettrace/evaluation/research.py +74 -0
- pettrace/experiments/__init__.py +1 -0
- pettrace/experiments/e1.py +289 -0
- pettrace/experiments/e3.py +43 -0
- pettrace/experiments/legacy.py +188 -0
- pettrace/models/__init__.py +1 -0
- pettrace/models/clip_reference.py +25 -0
- pettrace/models/golden.py +60 -0
- pettrace/models/pipeline.py +122 -0
- pettrace/tracking.py +156 -0
- pettrace/training/__init__.py +1 -0
- pettrace/training/features.py +67 -0
- pettrace/training/triplet.py +248 -0
- pettrace_ml-0.2.5.dist-info/METADATA +140 -0
- pettrace_ml-0.2.5.dist-info/RECORD +43 -0
- pettrace_ml-0.2.5.dist-info/WHEEL +4 -0
- pettrace_ml-0.2.5.dist-info/entry_points.txt +3 -0
- pettrace_ml-0.2.5.dist-info/licenses/LICENSE +21 -0
pettrace/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Reusable PetTrace ML logic for independently executable experiment notebooks.
|
|
2
|
+
|
|
3
|
+
Data loading, model inference, training, evaluation and tracking live in this package.
|
|
4
|
+
Experiment configuration, explanations, figures and findings live in notebooks/.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
__version__ = version("pettrace-ml")
|
|
11
|
+
except PackageNotFoundError:
|
|
12
|
+
__version__ = "0.2.5.dev3+source"
|
pettrace/_build.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.2.5",
|
|
3
|
+
"git_sha": "e1ff5289b6b8f0dd71eb8c418e85305b1dfc987c",
|
|
4
|
+
"git_dirty": false,
|
|
5
|
+
"uv_lock_sha256": "30a7a313b7fd2731d4f1d83ed02b6df386f44491709749ba1b6e998b01290dd4",
|
|
6
|
+
"reference_python": "3.11.15",
|
|
7
|
+
"uv": "uv 0.11.22 (x86_64-unknown-linux-gnu)"
|
|
8
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Lossless transport for JSON evidence exceeding the tracking proxy upload limit."""
|
|
2
|
+
import gzip
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import tempfile
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
|
|
9
|
+
CHUNK_BYTES = 512 * 1024
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def log_report_artifacts(local_dir, artifact_path=None, run_id=None):
|
|
13
|
+
import mlflow
|
|
14
|
+
|
|
15
|
+
root = Path(local_dir)
|
|
16
|
+
for path in sorted(root.rglob('*')):
|
|
17
|
+
if not path.is_file():
|
|
18
|
+
continue
|
|
19
|
+
relative = path.relative_to(root)
|
|
20
|
+
parent = (Path(artifact_path or '') / relative.parent).as_posix()
|
|
21
|
+
parent = None if parent == '.' else parent
|
|
22
|
+
if path.suffix != '.json' or path.stat().st_size <= CHUNK_BYTES:
|
|
23
|
+
mlflow.log_artifact(str(path), artifact_path=parent, run_id=run_id)
|
|
24
|
+
continue
|
|
25
|
+
# Keep local evidence untouched; only its remote representation changes.
|
|
26
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
27
|
+
packed = Path(tmp) / 'payload.gz'
|
|
28
|
+
digest = hashlib.sha256()
|
|
29
|
+
size = 0
|
|
30
|
+
with path.open('rb') as source, packed.open('wb') as target:
|
|
31
|
+
with gzip.GzipFile(filename='', fileobj=target, mode='wb', mtime=0) as output:
|
|
32
|
+
while block := source.read(CHUNK_BYTES):
|
|
33
|
+
digest.update(block)
|
|
34
|
+
size += len(block)
|
|
35
|
+
output.write(block)
|
|
36
|
+
parts = []
|
|
37
|
+
with packed.open('rb') as source:
|
|
38
|
+
while block := source.read(CHUNK_BYTES):
|
|
39
|
+
part = Path(tmp) / f'{path.name}.gz.part{len(parts):05d}'
|
|
40
|
+
part.write_bytes(block)
|
|
41
|
+
mlflow.log_artifact(str(part), artifact_path=parent, run_id=run_id)
|
|
42
|
+
parts.append({'name': part.name, 'size': len(block),
|
|
43
|
+
'sha256': hashlib.sha256(block).hexdigest()})
|
|
44
|
+
manifest = Path(tmp) / f'{path.name}.transport.json'
|
|
45
|
+
manifest.write_text(json.dumps({'schema': 1, 'original': path.name,
|
|
46
|
+
'size': size, 'sha256': digest.hexdigest(), 'encoding': 'gzip', 'parts': parts,
|
|
47
|
+
'restore': 'Concatenate parts in listed order, then gzip-decompress; verify original SHA-256.'}, indent=2))
|
|
48
|
+
mlflow.log_artifact(str(manifest), artifact_path=parent, run_id=run_id)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextmanager
|
|
52
|
+
def compact_json_uploads():
|
|
53
|
+
"""Allow an already-running v0.2.4 kernel to retry without replacing its package."""
|
|
54
|
+
import mlflow
|
|
55
|
+
|
|
56
|
+
original = mlflow.log_artifacts
|
|
57
|
+
mlflow.log_artifacts = log_report_artifacts
|
|
58
|
+
try:
|
|
59
|
+
yield
|
|
60
|
+
finally:
|
|
61
|
+
mlflow.log_artifacts = original
|
pettrace/cli.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Evaluate a `pettrace-embedder` version on a `pettrace-reid` version (thesis protocol).
|
|
2
|
+
|
|
3
|
+
make evaluate DATASET=v3 MODEL=models:/pettrace-embedder/1 PROTOCOL=zero-shot [REPORT=reports/e1_v3]
|
|
4
|
+
python -m pettrace.cli --model /path/to/model/dir --dataset /path/to/dataset/dir
|
|
5
|
+
|
|
6
|
+
The protocol, model identity and MLflow logging live in pettrace.evaluation.core (shared with the experiment
|
|
7
|
+
notebooks, which are the canonical record of each experiment). --report also writes the figures
|
|
8
|
+
and tables (pettrace.evaluation.figures) and attaches them to the run.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from pettrace.data import benchmark as data
|
|
16
|
+
from pettrace.evaluation.core import evaluate, load_model, log_run, report
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> int:
|
|
20
|
+
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
|
21
|
+
ap.add_argument("--model", default="models:/pettrace-embedder@production")
|
|
22
|
+
ap.add_argument("--dataset", required=True, help="version tag (v3) or a local dataset directory")
|
|
23
|
+
ap.add_argument("--repo", default=data.REPO)
|
|
24
|
+
ap.add_argument("--protocol", choices=("split", "zero-shot"), default="split",
|
|
25
|
+
help="zero-shot: every photo is a query (pretrained models only); EER on the same pairs")
|
|
26
|
+
ap.add_argument("--report", help="directory for summary.json, scores.json and figures")
|
|
27
|
+
ap.add_argument("--experiment", default="", help="E1…E5, tagged on the run")
|
|
28
|
+
args = ap.parse_args()
|
|
29
|
+
|
|
30
|
+
ds = data.from_dir(args.dataset) if os.path.isdir(args.dataset) else data.load(args.dataset, args.repo)
|
|
31
|
+
model = load_model(args.model)
|
|
32
|
+
if not model.golden_ok:
|
|
33
|
+
print(f"warning: {args.model} does not reproduce its golden fingerprint", file=sys.stderr)
|
|
34
|
+
ev = evaluate(ds, model.pipeline, zero_shot=args.protocol == "zero-shot")
|
|
35
|
+
print(report(ev.result))
|
|
36
|
+
if args.report:
|
|
37
|
+
from pettrace.evaluation.figures import save
|
|
38
|
+
|
|
39
|
+
save(ev, model, args.report)
|
|
40
|
+
print(f"report → {args.report}")
|
|
41
|
+
run_id = log_run(ev, model, experiment=args.experiment, artifacts=args.report)
|
|
42
|
+
print(f"run {run_id}")
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Dataset acquisition, preparation and versioned snapshots."""
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Acquire public body-ID datasets, retain provenance, then build an audited manifest."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import zipfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from pettrace.data.curation import audit_and_split, discover
|
|
13
|
+
|
|
14
|
+
MPDD_URL = "https://data.mendeley.com/public-files/datasets/v5j6m8dzhv/files/05d1d583-faf6-410d-89a5-a6b1134b6e5e/file_downloaded"
|
|
15
|
+
MPDD_SHA256 = "6c800c1b4aa67629544dec7444dee85bc57781abde1ae1d077ea9ef1804284cd"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def download(url, destination, expected_hash=None, auth=None):
|
|
19
|
+
destination = Path(destination)
|
|
20
|
+
digest = hashlib.sha256()
|
|
21
|
+
if not destination.exists():
|
|
22
|
+
partial = destination.with_suffix(".partial")
|
|
23
|
+
try:
|
|
24
|
+
with requests.get(url, stream=True, timeout=(30, 120), auth=auth) as response:
|
|
25
|
+
response.raise_for_status()
|
|
26
|
+
total = int(response.headers.get("Content-Length", 0))
|
|
27
|
+
received, last = 0, 0
|
|
28
|
+
with partial.open("wb") as f:
|
|
29
|
+
for block in response.iter_content(1 << 20):
|
|
30
|
+
if block:
|
|
31
|
+
f.write(block)
|
|
32
|
+
received += len(block)
|
|
33
|
+
if received - last >= 128 << 20:
|
|
34
|
+
print(f"Downloaded {received / 2**20:.0f} MiB / {total / 2**20:.0f} MiB", flush=True)
|
|
35
|
+
last = received
|
|
36
|
+
if not zipfile.is_zipfile(partial):
|
|
37
|
+
raise ValueError("Download is not a ZIP; authenticate/download manually from the source")
|
|
38
|
+
partial.replace(destination)
|
|
39
|
+
except Exception:
|
|
40
|
+
# Leave a clearly named partial file for diagnosis, never treat it as a completed dataset.
|
|
41
|
+
raise
|
|
42
|
+
with destination.open("rb") as f:
|
|
43
|
+
for block in iter(lambda: f.read(1 << 20), b""):
|
|
44
|
+
digest.update(block)
|
|
45
|
+
result = digest.hexdigest()
|
|
46
|
+
if expected_hash and result != expected_hash:
|
|
47
|
+
raise ValueError(f"Archive SHA-256 mismatch: {destination.name}")
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract(archive, destination):
|
|
52
|
+
destination = Path(destination).resolve()
|
|
53
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
with zipfile.ZipFile(archive) as z:
|
|
55
|
+
nested_archives = [destination / info.filename for info in z.infolist()
|
|
56
|
+
if info.filename.lower().endswith(".zip")]
|
|
57
|
+
for info in z.infolist():
|
|
58
|
+
target = (destination / info.filename).resolve()
|
|
59
|
+
if not target.is_relative_to(destination) or (info.external_attr >> 16) & 0o170000 == 0o120000:
|
|
60
|
+
raise ValueError("Unsafe archive path or symlink")
|
|
61
|
+
z.extractall(destination)
|
|
62
|
+
# Some publisher downloads are a wrapper ZIP containing the actual MPDD archive.
|
|
63
|
+
for nested in nested_archives:
|
|
64
|
+
with nested.open("rb") as f:
|
|
65
|
+
payload = io.BytesIO(f.read())
|
|
66
|
+
extract(payload, nested.parent)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def prepare(root, cat_root=None, mpdd_root=None, cat_version=None):
|
|
70
|
+
root = Path(root)
|
|
71
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
sources = []
|
|
73
|
+
if mpdd_root is None:
|
|
74
|
+
archive = root / "MPDD-v1.zip"
|
|
75
|
+
archive_hash = download(MPDD_URL, archive, MPDD_SHA256)
|
|
76
|
+
mpdd_root = root / "mpdd"
|
|
77
|
+
if not (mpdd_root / ".extracted").exists():
|
|
78
|
+
extract(archive, mpdd_root)
|
|
79
|
+
(mpdd_root / ".extracted").write_text(archive_hash)
|
|
80
|
+
sources.append({"name": "MPDD", "version": 1, "sha256": archive_hash,
|
|
81
|
+
"url": "https://data.mendeley.com/datasets/v5j6m8dzhv/1", "license": "CC BY 4.0"})
|
|
82
|
+
else:
|
|
83
|
+
sources.append({"name": "MPDD", "root": str(mpdd_root), "version": "user-provided archive",
|
|
84
|
+
"license": "CC BY 4.0; verify supplied source matches publisher"})
|
|
85
|
+
if cat_root is None:
|
|
86
|
+
auth = None
|
|
87
|
+
if os.environ.get("KAGGLE_USERNAME") and os.environ.get("KAGGLE_KEY"):
|
|
88
|
+
auth = (os.environ["KAGGLE_USERNAME"], os.environ["KAGGLE_KEY"])
|
|
89
|
+
metadata_url = "https://www.kaggle.com/api/v1/datasets/view/timost1234/cat-individuals"
|
|
90
|
+
if cat_version is None:
|
|
91
|
+
metadata = requests.get(metadata_url, timeout=30, auth=auth)
|
|
92
|
+
metadata.raise_for_status()
|
|
93
|
+
cat_version = metadata.json().get("currentVersionNumber")
|
|
94
|
+
if not cat_version:
|
|
95
|
+
raise ValueError("Cannot resolve CatIndividuals version; supply an extracted cat_root")
|
|
96
|
+
archive = root / f"CatIndividuals-v{cat_version}.zip"
|
|
97
|
+
url = f"https://www.kaggle.com/api/v1/datasets/download/timost1234/cat-individuals?datasetVersionNumber={cat_version}"
|
|
98
|
+
archive_hash = download(url, archive, auth=auth)
|
|
99
|
+
cat_root = root / f"cats-v{cat_version}"
|
|
100
|
+
if not (cat_root / ".extracted").exists():
|
|
101
|
+
extract(archive, cat_root)
|
|
102
|
+
(cat_root / ".extracted").write_text(archive_hash)
|
|
103
|
+
sources.append({"name": "CatIndividualImages", "version": cat_version, "sha256": archive_hash,
|
|
104
|
+
"url": "https://www.kaggle.com/datasets/timost1234/cat-individuals",
|
|
105
|
+
"license": "CC BY 4.0 per WildlifeDatasets 1.0.9; retain publisher terms"})
|
|
106
|
+
else:
|
|
107
|
+
sources.append({"name": "CatIndividualImages", "root": str(cat_root), "version": "user-provided archive",
|
|
108
|
+
"license": "Retain and verify the downloaded dataset's license"})
|
|
109
|
+
dogs, skipped_dogs = discover(mpdd_root, "mpdd", "dog")
|
|
110
|
+
cats, skipped_cats = discover(cat_root, "cat-individuals", "cat")
|
|
111
|
+
rows, audit = audit_and_split(dogs + cats, root / "curated")
|
|
112
|
+
(root / "curated/sources.json").write_text(json.dumps({"sources": sources,
|
|
113
|
+
"skipped_paths": skipped_dogs + skipped_cats}, indent=2))
|
|
114
|
+
print(json.dumps(audit["counts"], indent=2))
|
|
115
|
+
print("Conservative duplicate removals:", len(audit["removed_duplicates"]))
|
|
116
|
+
print("No automatic audit can confirm different photo sessions; inspect the public examples.")
|
|
117
|
+
return root / "curated/manifest.csv", rows, audit
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Load a pinned version of the `pettrace-reid` dataset (private Hugging Face dataset).
|
|
2
|
+
|
|
3
|
+
Training and evaluation read only published versions — never R2 or the app API — so every result
|
|
4
|
+
traces back to an immutable snapshot. Needs a read token in HF_TOKEN.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import csv
|
|
8
|
+
import hashlib
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
REPO = os.environ.get("HF_DATASET_REPO", "alessandro54/pettrace-reid")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Photo:
|
|
17
|
+
path: str # absolute path of the image
|
|
18
|
+
animal: str
|
|
19
|
+
species: str
|
|
20
|
+
split: str
|
|
21
|
+
light: float
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Dataset:
|
|
26
|
+
repo: str
|
|
27
|
+
version: str
|
|
28
|
+
commit: str # Hub commit the tag points to
|
|
29
|
+
fingerprint: str # sha256 of metadata.csv (first 16 hex): changes iff labels/photos change
|
|
30
|
+
root: str
|
|
31
|
+
animals: dict[str, dict] = field(default_factory=dict) # animal_id → animals.csv row
|
|
32
|
+
photos: list[Photo] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def name(self) -> str:
|
|
36
|
+
return f"{self.repo.split('/')[-1]}@{self.version}"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def url(self) -> str:
|
|
40
|
+
return f"https://huggingface.co/datasets/{self.repo}/tree/{self.version}"
|
|
41
|
+
|
|
42
|
+
def split(self, name: str) -> list[Photo]:
|
|
43
|
+
return [p for p in self.photos if p.split == name]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load(version: str, repo: str = REPO, cache_dir: str | None = None) -> Dataset:
|
|
47
|
+
from huggingface_hub import HfApi, snapshot_download
|
|
48
|
+
|
|
49
|
+
commit = HfApi().dataset_info(repo, revision=version).sha
|
|
50
|
+
root = snapshot_download(repo, repo_type="dataset", revision=commit, cache_dir=cache_dir)
|
|
51
|
+
return from_dir(root, repo=repo, version=version, commit=commit)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def from_dir(root: str, repo: str = REPO, version: str = "local", commit: str = "") -> Dataset:
|
|
55
|
+
meta_path = os.path.join(root, "metadata.csv")
|
|
56
|
+
with open(meta_path, "rb") as f:
|
|
57
|
+
fingerprint = hashlib.sha256(f.read()).hexdigest()[:16]
|
|
58
|
+
with open(os.path.join(root, "animals.csv"), newline="") as f:
|
|
59
|
+
animals = {row["animal_id"]: row for row in csv.DictReader(f)}
|
|
60
|
+
with open(meta_path, newline="") as f:
|
|
61
|
+
photos = [
|
|
62
|
+
Photo(
|
|
63
|
+
path=os.path.join(root, row["file_name"]),
|
|
64
|
+
animal=row["animal_id"],
|
|
65
|
+
species=row["species"],
|
|
66
|
+
split=row["split"],
|
|
67
|
+
light=float(row.get("light") or 0),
|
|
68
|
+
)
|
|
69
|
+
for row in csv.DictReader(f)
|
|
70
|
+
]
|
|
71
|
+
return Dataset(repo, version, commit, fingerprint, root, animals, photos)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Immutable incremental archives on mounted Drive; computation stays on local disk."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import sqlite3
|
|
7
|
+
import tarfile
|
|
8
|
+
import tempfile
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from pettrace.data.verification import sha256 as verified_sha256
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _sha(path):
|
|
17
|
+
with Path(path).open('rb') as stream:
|
|
18
|
+
return hashlib.file_digest(stream, 'sha256').hexdigest()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _safe(name):
|
|
22
|
+
path = PurePosixPath(name)
|
|
23
|
+
if path.is_absolute() or '..' in path.parts or '\\' in name or len(path.parts) < 2:
|
|
24
|
+
raise ValueError('Unsafe checkpoint path')
|
|
25
|
+
return path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DriveBackup:
|
|
29
|
+
"""One writer per backup directory. Only confirmed archives can be restored.
|
|
30
|
+
|
|
31
|
+
Call periodically at completed work units, and use as a context manager to
|
|
32
|
+
save on normal exit or Python exceptions. Abrupt VM loss retains the last
|
|
33
|
+
confirmed checkpoint, not the unfinished work since that checkpoint.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, roots, destination, *, interval=300, block_bytes=512 * 2**20):
|
|
37
|
+
self.roots = {name: Path(path).resolve() for name, path in roots.items()}
|
|
38
|
+
if not self.roots or any('/' in name or name in {'', '.', '..'} for name in self.roots):
|
|
39
|
+
raise ValueError('Use simple named checkpoint roots')
|
|
40
|
+
self.destination = Path(destination).resolve()
|
|
41
|
+
if interval < 0 or block_bytes <= 0:
|
|
42
|
+
raise ValueError('Invalid checkpoint interval or block size')
|
|
43
|
+
if any(self.destination.is_relative_to(p) or p.is_relative_to(self.destination)
|
|
44
|
+
for p in self.roots.values()):
|
|
45
|
+
raise ValueError('Backups and working directories must be separate')
|
|
46
|
+
self.interval, self.block_bytes = interval, block_bytes
|
|
47
|
+
self._saved = {}
|
|
48
|
+
self._pending_paths = set()
|
|
49
|
+
self._last = time.monotonic()
|
|
50
|
+
|
|
51
|
+
def _target(self, name):
|
|
52
|
+
relative = _safe(name)
|
|
53
|
+
root = self.roots.get(relative.parts[0])
|
|
54
|
+
if root is None:
|
|
55
|
+
raise ValueError('Unknown checkpoint root')
|
|
56
|
+
target = root.joinpath(*relative.parts[1:])
|
|
57
|
+
if not target.resolve().is_relative_to(root):
|
|
58
|
+
raise ValueError('Checkpoint path escapes working directory')
|
|
59
|
+
return target
|
|
60
|
+
|
|
61
|
+
def restore(self, *, only=None, files=None):
|
|
62
|
+
"""Restore selected roots or exact archive paths, verifying confirmed packs."""
|
|
63
|
+
if only is not None and not set(only) <= self.roots.keys():
|
|
64
|
+
raise ValueError("Unknown restore root")
|
|
65
|
+
if files is not None:
|
|
66
|
+
files = set(files)
|
|
67
|
+
for name in files:
|
|
68
|
+
self._target(name)
|
|
69
|
+
if only is not None and _safe(name).parts[0] not in only:
|
|
70
|
+
raise ValueError("Requested file is outside the selected restore roots")
|
|
71
|
+
latest, receipts = {}, {}
|
|
72
|
+
for receipt_path in sorted(self.destination.glob('*.json')):
|
|
73
|
+
receipt = json.loads(receipt_path.read_text())
|
|
74
|
+
if receipt.get('schema') != 1 or receipt.get('archive') != receipt_path.with_suffix('.tar').name:
|
|
75
|
+
raise ValueError('Invalid checkpoint receipt')
|
|
76
|
+
for name, info in receipt['files'].items():
|
|
77
|
+
if files is not None and name not in files:
|
|
78
|
+
continue
|
|
79
|
+
label = _safe(name).parts[0]
|
|
80
|
+
if label not in self.roots:
|
|
81
|
+
raise ValueError('Unknown checkpoint root')
|
|
82
|
+
if only is None or label in only:
|
|
83
|
+
self._target(name)
|
|
84
|
+
receipts[receipt_path.name] = receipt
|
|
85
|
+
latest[name] = (receipt_path.name, info)
|
|
86
|
+
if files is not None and files - latest.keys():
|
|
87
|
+
raise FileNotFoundError(f"No confirmed Drive backup for: {', '.join(sorted(files - latest.keys()))}")
|
|
88
|
+
needed = {}
|
|
89
|
+
for name, (receipt_name, info) in latest.items():
|
|
90
|
+
target = self._target(name)
|
|
91
|
+
if not target.is_file() or target.stat().st_size != info['size'] or verified_sha256(target) != info['sha256']:
|
|
92
|
+
needed.setdefault(receipt_name, set()).add(name)
|
|
93
|
+
for receipt_name, names in needed.items():
|
|
94
|
+
receipt = receipts[receipt_name]
|
|
95
|
+
with tempfile.TemporaryDirectory(prefix='pettrace-restore-') as folder:
|
|
96
|
+
local = Path(folder) / 'block.tar'
|
|
97
|
+
shutil.copyfile(self.destination / receipt['archive'], local)
|
|
98
|
+
if _sha(local) != receipt['sha256']:
|
|
99
|
+
raise ValueError('Checkpoint archive checksum mismatch')
|
|
100
|
+
with tarfile.open(local) as archive:
|
|
101
|
+
members = archive.getmembers()
|
|
102
|
+
if len({m.name for m in members}) != len(members) or {m.name for m in members} != set(receipt['files']):
|
|
103
|
+
raise ValueError('Checkpoint inventory mismatch')
|
|
104
|
+
for member in members:
|
|
105
|
+
self._target(member.name)
|
|
106
|
+
if not member.isfile() or member.size != receipt['files'][member.name]['size']:
|
|
107
|
+
raise ValueError('Unsafe checkpoint member')
|
|
108
|
+
for member in members:
|
|
109
|
+
if member.name not in names:
|
|
110
|
+
continue
|
|
111
|
+
target = self._target(member.name)
|
|
112
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as stream:
|
|
114
|
+
partial = Path(stream.name)
|
|
115
|
+
with archive.extractfile(member) as source:
|
|
116
|
+
shutil.copyfileobj(source, stream)
|
|
117
|
+
try:
|
|
118
|
+
if _sha(partial) != receipt['files'][member.name]['sha256']:
|
|
119
|
+
raise ValueError('Checkpoint member checksum mismatch')
|
|
120
|
+
partial.replace(target)
|
|
121
|
+
finally:
|
|
122
|
+
partial.unlink(missing_ok=True)
|
|
123
|
+
print(f'Drive: bloque restaurado ({len(names)} archivos).', flush=True)
|
|
124
|
+
for name in latest:
|
|
125
|
+
self._saved[name] = self._signature(self._target(name))
|
|
126
|
+
print(f'Drive: {len(latest)} archivos verificados; respaldo listo.', flush=True)
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _signature(path):
|
|
130
|
+
stat = path.stat()
|
|
131
|
+
return stat.st_size, stat.st_mtime_ns
|
|
132
|
+
|
|
133
|
+
def __call__(self, *, force=False, paths=None):
|
|
134
|
+
"""Back up supplied files/subtrees, or scan all roots when paths is omitted.
|
|
135
|
+
|
|
136
|
+
Paths accumulate until the next checkpoint; context exit always scans all
|
|
137
|
+
roots so exceptions and callers without change tracking remain recoverable.
|
|
138
|
+
"""
|
|
139
|
+
if paths is not None:
|
|
140
|
+
for path in paths:
|
|
141
|
+
path = Path(path).absolute()
|
|
142
|
+
match = next(((label, root) for label, root in self.roots.items() if path.is_relative_to(root)), None)
|
|
143
|
+
if match is None:
|
|
144
|
+
raise ValueError('Checkpoint path is outside its roots')
|
|
145
|
+
label, root = match
|
|
146
|
+
if path != root:
|
|
147
|
+
self._target(f'{label}/{path.relative_to(root).as_posix()}')
|
|
148
|
+
if path.is_symlink():
|
|
149
|
+
raise ValueError('Checkpoint roots must contain regular files, not symlinks')
|
|
150
|
+
self._pending_paths.add(path)
|
|
151
|
+
if not force and time.monotonic() - self._last < self.interval:
|
|
152
|
+
return
|
|
153
|
+
def candidates():
|
|
154
|
+
for label, root in self.roots.items():
|
|
155
|
+
if not root.exists():
|
|
156
|
+
continue
|
|
157
|
+
selected = {root} if paths is None else {p for p in self._pending_paths if p.is_relative_to(root)}
|
|
158
|
+
for path in sorted(selected):
|
|
159
|
+
if any(parent in selected for parent in path.parents):
|
|
160
|
+
continue
|
|
161
|
+
yield from ((label, p) for p in (path.rglob('*') if path.is_dir() else [path]))
|
|
162
|
+
|
|
163
|
+
pending, size = [], 0
|
|
164
|
+
for label, path in candidates():
|
|
165
|
+
relative = path.relative_to(self.roots[label])
|
|
166
|
+
# Incomplete outputs, Hub internals and SQLite journals are not snapshots.
|
|
167
|
+
if any(p.startswith('.') for p in relative.parts) or path.name.endswith(('.partial', '-wal', '-shm', '-journal')):
|
|
168
|
+
continue
|
|
169
|
+
name = f'{label}/{relative.as_posix()}'
|
|
170
|
+
if path.is_symlink():
|
|
171
|
+
raise ValueError('Checkpoint roots must contain regular files, not symlinks')
|
|
172
|
+
if not path.is_file():
|
|
173
|
+
continue
|
|
174
|
+
signature = self._signature(path)
|
|
175
|
+
if self._saved.get(name) == signature:
|
|
176
|
+
continue
|
|
177
|
+
pending.append((name, path, signature))
|
|
178
|
+
size += signature[0]
|
|
179
|
+
if size >= self.block_bytes:
|
|
180
|
+
self._save_block(pending)
|
|
181
|
+
pending, size = [], 0
|
|
182
|
+
if pending:
|
|
183
|
+
self._save_block(pending)
|
|
184
|
+
self._pending_paths.clear()
|
|
185
|
+
self._last = time.monotonic()
|
|
186
|
+
|
|
187
|
+
def _save_block(self, files):
|
|
188
|
+
self.destination.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
key = f'{time.time_ns():020d}-{uuid4().hex}'
|
|
190
|
+
with tempfile.TemporaryDirectory(prefix='pettrace-checkpoint-') as folder:
|
|
191
|
+
folder = Path(folder)
|
|
192
|
+
pack = folder / f'{key}.tar'
|
|
193
|
+
inventory = {}
|
|
194
|
+
with tarfile.open(pack, 'w') as archive:
|
|
195
|
+
for name, path, signature in files:
|
|
196
|
+
source = path
|
|
197
|
+
if path.suffix == '.sqlite3':
|
|
198
|
+
source = folder / 'snapshot.sqlite3'
|
|
199
|
+
source.unlink(missing_ok=True)
|
|
200
|
+
with sqlite3.connect(f'{path.as_uri()}?mode=ro', uri=True) as db, sqlite3.connect(source) as copy:
|
|
201
|
+
db.backup(copy)
|
|
202
|
+
inventory[name] = {'size': source.stat().st_size, 'sha256': _sha(source)}
|
|
203
|
+
archive.add(source, arcname=name, recursive=False)
|
|
204
|
+
if self._signature(path) != signature:
|
|
205
|
+
raise RuntimeError('File changed during backup; retry at a completed work unit')
|
|
206
|
+
digest = _sha(pack)
|
|
207
|
+
remote = self.destination / pack.name
|
|
208
|
+
partial = remote.with_suffix('.tar.partial')
|
|
209
|
+
shutil.copyfile(pack, partial)
|
|
210
|
+
if _sha(partial) != digest:
|
|
211
|
+
raise IOError('Drive backup verification failed')
|
|
212
|
+
partial.replace(remote)
|
|
213
|
+
receipt = {'schema': 1, 'archive': remote.name, 'sha256': digest, 'files': inventory}
|
|
214
|
+
temporary = remote.with_suffix('.json.partial')
|
|
215
|
+
temporary.write_text(json.dumps(receipt, sort_keys=True))
|
|
216
|
+
if json.loads(temporary.read_text()) != receipt:
|
|
217
|
+
raise IOError('Drive receipt verification failed')
|
|
218
|
+
temporary.replace(remote.with_suffix('.json'))
|
|
219
|
+
for name, _, signature in files:
|
|
220
|
+
self._saved[name] = signature
|
|
221
|
+
print(f'Drive: respaldo confirmado ({len(files)} archivos, {pack.name}).', flush=True)
|
|
222
|
+
|
|
223
|
+
def __enter__(self):
|
|
224
|
+
return self
|
|
225
|
+
|
|
226
|
+
def __exit__(self, exc_type, exc, traceback):
|
|
227
|
+
self(force=True)
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def stage_archive(source, destination):
|
|
232
|
+
"""Copy a Drive ZIP to local scratch; acquisition verifies its pinned index and CRCs."""
|
|
233
|
+
source, destination = Path(source).resolve(), Path(destination).resolve()
|
|
234
|
+
if source == destination:
|
|
235
|
+
raise ValueError('ZIP staging must use a separate local destination')
|
|
236
|
+
stat = source.stat()
|
|
237
|
+
signature = {'size': stat.st_size, 'mtime_ns': stat.st_mtime_ns}
|
|
238
|
+
receipt = destination.with_suffix(destination.suffix + '.json')
|
|
239
|
+
if destination.is_file() and receipt.is_file() and json.loads(receipt.read_text()) == signature:
|
|
240
|
+
if destination.stat().st_size == stat.st_size:
|
|
241
|
+
return destination
|
|
242
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
if shutil.disk_usage(destination.parent).free < stat.st_size + 64 * 2**20:
|
|
244
|
+
raise OSError("Insufficient local disk space to stage the source ZIP")
|
|
245
|
+
partial = destination.with_suffix(destination.suffix + '.partial')
|
|
246
|
+
with source.open('rb') as incoming, partial.open('wb') as outgoing:
|
|
247
|
+
copied, last = 0, time.monotonic()
|
|
248
|
+
while block := incoming.read(8 * 2**20):
|
|
249
|
+
outgoing.write(block)
|
|
250
|
+
copied += len(block)
|
|
251
|
+
if time.monotonic() - last >= 5:
|
|
252
|
+
print(f'\rZIP {source.name}: {copied / 2**30:.1f}/{stat.st_size / 2**30:.1f} GiB', end='', flush=True)
|
|
253
|
+
last = time.monotonic()
|
|
254
|
+
if source.stat().st_mtime_ns != stat.st_mtime_ns or copied != stat.st_size:
|
|
255
|
+
raise IOError('Source ZIP changed during transfer')
|
|
256
|
+
partial.replace(destination)
|
|
257
|
+
receipt.write_text(json.dumps(signature))
|
|
258
|
+
print(f'\rZIP {source.name}: copia local lista; se verificará índice y CRC al extraer.', flush=True)
|
|
259
|
+
return destination
|