"""Download every source listed in configs/sources.yaml into data/raw// and record SHA-256, size, HTTP status, retrieval timestamp in data/metadata/source_registry.json. Usage: python -m wp35.acquisition.fetch [--only KEY ...] [--force] Raw files are immutable: a re-run verifies the hash of an existing file and never overwrites it unless --force is given. """ from __future__ import annotations import argparse, hashlib, json, sys, time from datetime import datetime, timezone from pathlib import Path import requests, yaml ROOT = Path(__file__).resolve().parents[3] RAW = ROOT / "data" / "raw" META = ROOT / "data" / "metadata" REGISTRY = META / "source_registry.json" UA = "WP35-UQO-research-acquisition/0.1 (simon-pierre.boucher@uqo.ca)" def sha256(path: Path) -> str: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def load_registry() -> dict: return json.loads(REGISTRY.read_text()) if REGISTRY.exists() else {} def fetch(key: str, spec: dict, force: bool, registry: dict) -> dict: dest_dir = RAW / key dest_dir.mkdir(parents=True, exist_ok=True) dest = dest_dir / spec["filename"] rec = dict(spec) rec["key"] = key rec["local_path"] = str(dest.relative_to(ROOT)) if dest.exists() and not force: rec["status"] = "already_present" rec["sha256"] = sha256(dest) rec["size_bytes"] = dest.stat().st_size prev = registry.get(key, {}) rec["retrieved_at_utc"] = prev.get("retrieved_at_utc") rec["http_status"] = prev.get("http_status") if prev.get("sha256") and prev["sha256"] != rec["sha256"]: rec["status"] = "HASH_MISMATCH_WITH_REGISTRY" return rec t0 = time.time() r = requests.get(spec["url"], headers={"User-Agent": UA, "Accept": "*/*"}, timeout=300, stream=True, allow_redirects=True) rec["http_status"] = r.status_code rec["final_url"] = r.url rec["content_type"] = r.headers.get("Content-Type") if r.status_code != 200: rec["status"] = f"download_failed_http_{r.status_code}" return rec tmp = dest.with_suffix(dest.suffix + ".part") with open(tmp, "wb") as f: for chunk in r.iter_content(1 << 20): f.write(chunk) tmp.rename(dest) rec["retrieved_at_utc"] = datetime.now(timezone.utc).isoformat(timespec="seconds") rec["elapsed_s"] = round(time.time() - t0, 2) rec["size_bytes"] = dest.stat().st_size rec["sha256"] = sha256(dest) rec["status"] = "downloaded" return rec def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument("--only", nargs="*") ap.add_argument("--force", action="store_true") a = ap.parse_args(argv) specs = yaml.safe_load((ROOT / "configs" / "sources.yaml").read_text())["sources"] META.mkdir(parents=True, exist_ok=True) registry = load_registry() keys = a.only or list(specs) for k in keys: rec = fetch(k, specs[k], a.force, registry) registry[k] = rec print(f"{k:40s} {rec['status']:22s} {rec.get('size_bytes','?'):>10} B sha256={str(rec.get('sha256',''))[:16]}…") REGISTRY.write_text(json.dumps(registry, indent=2, ensure_ascii=False)) bad = [k for k in keys if not registry[k]["status"] in ("downloaded", "already_present")] if bad: print("FAILED:", bad, file=sys.stderr) return 1 return 0 if __name__ == "__main__": sys.exit(main())