open-navigator / scripts /download_bronze.py
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
17.1 kB
#!/usr/bin/env python3
"""
Master Bronze Download Runner
Downloads all source data files to the local cache before bronze ingestion.
Each step's stdout/stderr is streamed to the terminal AND saved to a timestamped
log file under logs/download_bronze/<run_id>/.
Downloaders (run in order):
1. gazetteer — Jurisdictions (Gazetteer → gold parquet) → census/download_census_gazetteer.py
2. shapefiles — Boundary shapefiles (states/counties/zcta/places) → census/download_census_shapefiles.py
3. school_districts — School district shapefiles (TIGER/Line) → census/download_census_school_districts.py
4. relationships — ZCTA-County / ZCTA-Place crosswalks → census/download_census_relationships.py
5. municipalities — Municipalities Gazetteer CSV → census/download_census_municipalities.py
6. acs — ACS demographic tables (Census API) → census/download_census_acs_data.py
Usage:
python scripts/download_bronze.py # download everything
python scripts/download_bronze.py --only shapefiles # one step only
python scripts/download_bronze.py --skip gazetteer # skip one step
python scripts/download_bronze.py --force # re-download even if cached
python scripts/download_bronze.py --extract # extract shapefile ZIPs after download
python scripts/download_bronze.py --year 2024 # use a different Census vintage year
python scripts/download_bronze.py --dry-run # print commands without running
python scripts/download_bronze.py --retry-failed # re-run only steps that failed last time
python scripts/download_bronze.py --retry-run 20260507_120000
"""
import sys
import json
import subprocess
import argparse
import time
import threading
from datetime import datetime
from pathlib import Path
from loguru import logger
sys.path.insert(0, str(Path(__file__).parent.parent))
from scripts.utils.log_sync import sync_logs, MACHINE_ID
PROJECT_ROOT = Path(__file__).parent.parent
LOG_ROOT = PROJECT_ROOT / "logs" / "download_bronze"
DOWNLOADERS = [
{
"key": "gazetteer",
"label": "Jurisdictions Gazetteer (Census → gold parquet)",
"script": "scripts/datasources/census/download_census_gazetteer.py",
"cache_dirs": ["data/cache/census/gazetteer", "data/gold"],
"supports_force": False,
"supports_year": False,
"supports_extract": False,
},
{
"key": "shapefiles",
"label": "Boundary Shapefiles (states / counties / zcta / places)",
"script": "scripts/datasources/census/download_census_shapefiles.py",
"cache_dirs": ["data/cache/census/shapefiles"],
"supports_force": False,
"supports_year": True,
"supports_extract": True,
},
{
"key": "school_districts",
"label": "School District Shapefiles (unified / elementary / secondary)",
"script": "scripts/datasources/census/download_census_school_districts.py",
"cache_dirs": ["data/cache/census/school_districts"],
"supports_force": False,
"supports_year": True,
"supports_extract": True,
},
{
"key": "relationships",
"label": "Geographic Relationships (ZCTA-County, ZCTA-Place)",
"script": "scripts/datasources/census/download_census_relationships.py",
"cache_dirs": ["data/cache/census_relationships"],
"supports_force": True,
"supports_year": False,
"supports_extract": False,
},
{
"key": "municipalities",
"label": "Municipalities Gazetteer CSV (Census Places)",
"script": "scripts/datasources/census/download_census_municipalities.py",
"cache_dirs": ["data/cache/census"],
"supports_force": True,
"supports_year": False,
"supports_extract": False,
},
{
# ACS uses its own vintage (latest 5-yr release = 2022) which is decoupled
# from the TIGER vintage used by other steps, so supports_year is False here.
# To use a non-default ACS year, run download_census_acs_data.py directly.
"key": "acs",
"label": "ACS Demographic Tables (Census API → parquet)",
"script": "scripts/datasources/census/download_census_acs_data.py",
"cache_dirs": ["data/cache/census/acs"],
"supports_force": True,
"supports_year": False,
"supports_extract": False,
},
]
# ---------------------------------------------------------------------------
# Results persistence
# ---------------------------------------------------------------------------
def save_results(results: list[dict], log_dir: Path, started_at: datetime) -> None:
payload = {
"run_id": log_dir.name,
"started_at": started_at.isoformat(),
"finished_at": datetime.now().isoformat(),
"steps": [
{
"key": r["key"],
"label": r["label"],
"ok": r["ok"],
"exit_code": r["exit_code"],
"elapsed": round(r["elapsed"], 2),
}
for r in results
],
}
(log_dir / "results.json").write_text(json.dumps(payload, indent=2))
def load_failed_keys(run_id: str | None) -> tuple[list[str], str]:
if run_id:
run_dir = LOG_ROOT / run_id
else:
runs = sorted(LOG_ROOT.iterdir()) if LOG_ROOT.exists() else []
runs = [r for r in runs if r.is_dir() and (r / "results.json").exists()]
if not runs:
raise FileNotFoundError(f"No completed runs found in {LOG_ROOT}")
run_dir = runs[-1]
results_file = run_dir / "results.json"
if not results_file.exists():
raise FileNotFoundError(f"No results.json in {run_dir}. Was this run completed?")
data = json.loads(results_file.read_text())
failed = [s["key"] for s in data["steps"] if not s["ok"]]
return failed, data["run_id"]
# ---------------------------------------------------------------------------
# Cache size helpers
# ---------------------------------------------------------------------------
def dir_size_bytes(dirs: list[str]) -> int:
total = 0
for d in dirs:
p = PROJECT_ROOT / d
if p.exists():
total += sum(f.stat().st_size for f in p.rglob("*") if f.is_file())
return total
def fmt_bytes(n: int) -> str:
if n >= 1024 ** 3:
return f"{n / 1024 ** 3:.1f} GB"
if n >= 1024 ** 2:
return f"{n / 1024 ** 2:.1f} MB"
if n >= 1024:
return f"{n / 1024:.1f} KB"
return f"{n} B"
# ---------------------------------------------------------------------------
# Subprocess helpers
# ---------------------------------------------------------------------------
def stream_to_file(stream, log_file, also_stdout: bool = True) -> None:
for line in iter(stream.readline, ""):
if also_stdout:
sys.stdout.write(line)
sys.stdout.flush()
log_file.write(line)
log_file.flush()
def run_subprocess(cmd: list, log_path: Path, cwd: Path = PROJECT_ROOT) -> int:
log_path.parent.mkdir(parents=True, exist_ok=True)
with open(log_path, "w") as log_file:
proc = subprocess.Popen(
cmd, cwd=cwd,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1,
)
t = threading.Thread(target=stream_to_file, args=(proc.stdout, log_file))
t.start()
proc.wait()
t.join()
return proc.returncode
# ---------------------------------------------------------------------------
# Downloader runner
# ---------------------------------------------------------------------------
def run_downloader(
downloader: dict,
force: bool,
extract: bool,
year: int,
dry_run: bool,
log_dir: Path,
) -> dict:
cache_dirs = downloader.get("cache_dirs", [])
size_before = dir_size_bytes(cache_dirs)
cmd = [sys.executable, downloader["script"]]
if year and downloader["supports_year"]:
cmd += ["--year", str(year)]
if force and downloader["supports_force"]:
cmd.append("--force")
if extract and downloader["supports_extract"]:
cmd.append("--extract")
log_path = log_dir / f"{downloader['key']}.log"
logger.info(f"$ {' '.join(str(c) for c in cmd)}")
logger.info(f" log → {log_path.relative_to(PROJECT_ROOT)}")
if dry_run:
logger.info(" [dry-run] skipping execution")
return {
"key": downloader["key"],
"label": downloader["label"],
"exit_code": 0,
"elapsed": 0.0,
"ok": True,
"dry_run": True,
"size_before": size_before,
"size_after": size_before,
"log_path": None,
}
start = time.monotonic()
exit_code = run_subprocess(cmd, log_path)
elapsed = time.monotonic() - start
size_after = dir_size_bytes(cache_dirs)
return {
"key": downloader["key"],
"label": downloader["label"],
"exit_code": exit_code,
"elapsed": elapsed,
"ok": exit_code == 0,
"dry_run": False,
"size_before": size_before,
"size_after": size_after,
"log_path": log_path,
}
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
def fmt_elapsed(seconds: float) -> str:
m, s = divmod(seconds, 60)
return f"{int(m)}m {s:.1f}s" if m else f"{s:.1f}s"
def print_summary(results: list[dict], started_at: datetime, log_dir: Path) -> None:
duration = (datetime.now() - started_at).total_seconds()
n_ok = sum(1 for r in results if r["ok"])
n_failed = len(results) - n_ok
sep = "=" * 80
thin = "-" * 80
logger.info("")
logger.info(sep)
logger.info(" BRONZE DOWNLOAD SUMMARY")
logger.info(f" Started : {started_at.strftime('%Y-%m-%d %H:%M:%S')}")
logger.info(f" Finished: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ({fmt_elapsed(duration)} total)")
logger.info(f" Logs : {log_dir.relative_to(PROJECT_ROOT)}/")
logger.info(thin)
logger.info(f" {'STATUS':<12} {'TIME':>8} {'DOWNLOADED':>12} {'CACHE':>10} STEP")
logger.info(thin)
for r in results:
tag = "dry-run" if r.get("dry_run") else ("OK" if r["ok"] else f"FAIL({r['exit_code']})")
added = r["size_after"] - r["size_before"]
added_str = (f"+{fmt_bytes(added)}" if added > 0 else "cached").rjust(12)
cache_str = fmt_bytes(r["size_after"]).rjust(10)
logger.info(
f" {tag:<12} {fmt_elapsed(r['elapsed']):>8}"
f" {added_str}"
f" {cache_str}"
f" {r['label']}"
)
log_path = r.get("log_path")
if log_path and not r["ok"]:
rel = log_path.relative_to(PROJECT_ROOT)
logger.error(f" {'':12} {'':8} {'':12} {'':10} log → {rel}")
logger.info(thin)
if n_failed:
logger.error(f" {n_ok} succeeded, {n_failed} failed")
logger.info(f" To retry: python scripts/download_bronze.py --retry-failed")
else:
logger.success(f" {n_ok} succeeded")
logger.info(sep)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(
description="Download all bronze source data files to local cache",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
Download everything:
python scripts/download_bronze.py
Re-download everything even if cached:
python scripts/download_bronze.py --force
Download shapefiles and extract them:
python scripts/download_bronze.py --only shapefiles school_districts --extract
Use a different Census vintage year:
python scripts/download_bronze.py --year 2022
Preview what would run without downloading:
python scripts/download_bronze.py --dry-run
Retry only failed steps from the last run:
python scripts/download_bronze.py --retry-failed
Available downloaders:
gazetteer Jurisdictions Gazetteer (→ gold parquet)
shapefiles Boundary shapefiles: states, counties, zcta, places
school_districts School district shapefiles: unified, elementary, secondary
relationships ZCTA-to-County and ZCTA-to-Place crosswalks
municipalities Municipalities Gazetteer CSV
acs ACS demographic tables via Census API (→ parquet cache)
""",
)
keys = [d["key"] for d in DOWNLOADERS]
retry_group = parser.add_mutually_exclusive_group()
retry_group.add_argument(
"--retry-failed", action="store_true",
help="Re-run only the steps that failed in the most recent run",
)
retry_group.add_argument(
"--retry-run", metavar="RUN_ID",
help="Re-run only the steps that failed in a specific run (e.g. 20260507_120000)",
)
filter_group = parser.add_mutually_exclusive_group()
filter_group.add_argument(
"--skip", nargs="+", choices=keys, metavar="KEY",
help=f"Downloaders to skip. Choices: {', '.join(keys)}",
)
filter_group.add_argument(
"--only", nargs="+", choices=keys, metavar="KEY",
help="Run only these downloaders",
)
parser.add_argument(
"--force", action="store_true",
help="Re-download files even if they already exist in cache",
)
parser.add_argument(
"--extract", action="store_true",
help="Extract shapefile ZIP archives after downloading",
)
parser.add_argument(
"--year", type=int, default=2025, metavar="YEAR",
help="Census vintage year for shapefiles (default: 2025)",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Print commands that would run without executing them",
)
args = parser.parse_args()
# Resolve --retry-failed / --retry-run into an --only list
retry_only: list[str] | None = None
if args.retry_failed or args.retry_run:
try:
run_id = args.retry_run if args.retry_run else None
failed_keys, source_run = load_failed_keys(run_id)
except (FileNotFoundError, ValueError) as e:
logger.error(str(e))
return 1
if not failed_keys:
logger.success(f"Run {source_run} had no failures — nothing to retry.")
return 0
retry_only = failed_keys
logger.info(
f"Retrying {len(failed_keys)} failed step(s) from run {source_run}: {', '.join(failed_keys)}"
)
started_at = datetime.now()
run_id = started_at.strftime("%Y%m%d_%H%M%S")
log_dir = LOG_ROOT / run_id
log_dir.mkdir(parents=True, exist_ok=True)
orchestrator_log = log_dir / "orchestrator.log"
logger.add(
orchestrator_log, level="DEBUG",
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}",
)
logger.info("=" * 80)
logger.info(" OPEN NAVIGATOR — BRONZE DOWNLOAD")
logger.info(f" {started_at.strftime('%Y-%m-%d %H:%M:%S')} (run: {run_id}) machine: {MACHINE_ID}")
logger.info("=" * 80)
active_keys = retry_only
downloaders = list(DOWNLOADERS)
if active_keys is not None:
downloaders = [d for d in downloaders if d["key"] in active_keys]
elif args.only:
downloaders = [d for d in downloaders if d["key"] in args.only]
elif args.skip:
downloaders = [d for d in downloaders if d["key"] not in args.skip]
if args.dry_run:
logger.warning("--dry-run active: commands will be printed but not executed")
if args.force:
logger.info("--force active: cached files will be re-downloaded where supported")
if args.extract:
logger.info("--extract active: shapefile ZIPs will be extracted after download")
logger.info(f"Census year: {args.year}")
logger.info("")
results: list[dict] = []
def checkpoint() -> None:
save_results(results, log_dir, started_at)
sync_logs(log_dir, run_type="download_bronze", project_root=PROJECT_ROOT)
for downloader in downloaders:
logger.info(f" {'─' * 76}")
logger.info(f" ▶ {downloader['label']}")
logger.info(f" {'─' * 76}")
result = run_downloader(
downloader,
force=args.force,
extract=args.extract,
year=args.year,
dry_run=args.dry_run,
log_dir=log_dir,
)
results.append(result)
if not result["ok"]:
logger.error(f" Downloader exited with code {result['exit_code']} — continuing to next step")
checkpoint()
logger.info("")
print_summary(results, started_at, log_dir)
failed = [r for r in results if not r["ok"]]
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())