open-navigator / scripts /load_bronze.py
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
20.7 kB
#!/usr/bin/env python3
"""
Master Bronze Ingestion Runner
Runs all bronze-layer data loaders in sequence, then triggers dbt bronze models.
Each step's stdout/stderr is streamed to the terminal AND saved to a timestamped
log file under logs/load_bronze/<run_id>/.
Loaders (run in order):
1. census — Jurisdictions → scripts/datasources/census/load_census_gazetteer.py
2. gsa — Gov Websites → scripts/datasources/gsa/load_gsa_domains_to_postgres.py
3. localview — Meetings (Old) → scripts/datasources/localview/load_localview_to_postgres.py
4. irs — Non-Profits → scripts/datasources/irs/load_irs_bmf.py
5. enrich_ai — AI Meeting Analysis (Gemini) → packages/llm/src/llm/enrichment/load_enriched_events_ai.py --only analyze
6. hud_zip_county — ZIP-County Crosswalk (HUD) → packages/ingestion/src/ingestion/hud/zip_county.py
7. shapefiles — Geometry Shapefiles (Census TIGER) → scripts/datasources/census/load_census_shapefiles.py
8. place_crosswalks — Place → County / ZCTA Crosswalks → scripts/datasources/census/load_place_crosswalks.py
Usage:
python scripts/load_bronze.py # run all loaders + dbt bronze
python scripts/load_bronze.py --retry-failed # re-run only failed steps from last run
python scripts/load_bronze.py --retry-run 20260507_082303 # retry a specific run by ID
python scripts/load_bronze.py --skip localview irs # skip specific loaders
python scripts/load_bronze.py --only gsa census # run specific loaders only
python scripts/load_bronze.py --truncate # clear tables before loading
python scripts/load_bronze.py --dry-run # parse only, no DB writes
python scripts/load_bronze.py --no-dbt # skip the dbt run at the end
"""
import sys
import os
import json
import subprocess
import argparse
import time
import threading
from datetime import datetime
from pathlib import Path
import psycopg2
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
DBT_PROJECT_DIR = PROJECT_ROOT / "dbt_project"
LOG_ROOT = PROJECT_ROOT / "logs" / "load_bronze"
_PG_PASSWORD = os.getenv("POSTGRES_PASSWORD", "password")
DATABASE_URL = f"postgresql://postgres:{_PG_PASSWORD}@localhost:5433/open_navigator"
LOADERS = [
{
"key": "census",
"label": "Jurisdictions (Census Gazetteer)",
"script": "scripts/datasources/census/load_census_gazetteer.py",
"supports_truncate": False,
"supports_dry_run": False,
"tables": [
"bronze.bronze_jurisdictions_counties",
"bronze.bronze_jurisdictions_municipalities",
"bronze.bronze_jurisdictions_school_districts",
"bronze.bronze_jurisdictions_townships",
"bronze.bronze_jurisdictions_zcta",
],
},
{
"key": "gsa",
"label": "Gov Websites (GSA Domains)",
"script": "scripts/datasources/gsa/load_gsa_domains_to_postgres.py",
"supports_truncate": True,
"supports_dry_run": True,
"tables": ["bronze.bronze_gov_domains"],
},
{
"key": "localview",
"label": "Meetings (LocalView)",
"script": "scripts/datasources/localview/load_localview_to_postgres.py",
"supports_truncate": False,
"supports_dry_run": False,
"tables": ["bronze.bronze_events_localview"],
},
{
"key": "irs",
"label": "Non-Profits (IRS BMF)",
"script": "scripts/datasources/irs/load_irs_bmf.py",
"supports_truncate": False,
"supports_dry_run": False,
"tables": ["bronze.bronze_organizations_nonprofits_irs"],
},
{
"key": "enrich_ai",
"label": "AI Meeting Analysis (Gemini)",
"script": "packages/llm/src/llm/enrichment/load_enriched_events_ai.py",
"extra_args": ["--only", "analyze"],
"supports_truncate": False,
"supports_dry_run": True,
"tables": ["bronze.bronze_events_analysis_ai"],
},
{
"key": "hud_zip_county",
"label": "ZIP-County Crosswalk (HUD)",
"script": "packages/ingestion/src/ingestion/hud/zip_county.py",
"supports_truncate": True,
"supports_dry_run": True,
"tables": ["bronze.bronze_jurisdictions_zip_county"],
},
{
"key": "shapefiles",
"label": "Geometry Shapefiles (Census TIGER)",
"script": "scripts/datasources/census/load_census_shapefiles.py",
"supports_truncate": True,
"supports_dry_run": True,
"tables": [
"bronze.bronze_geo_states",
"bronze.bronze_geo_counties",
"bronze.bronze_geo_places",
"bronze.bronze_geo_zcta",
],
},
{
# Depends on shapefiles being downloaded (place + county) and on the
# zcta_place relationship file being downloaded. Computes its own
# spatial overlay rather than relying on bronze_geo_* tables, so it
# can run independently of the `shapefiles` step having succeeded.
"key": "place_crosswalks",
"label": "Place → County / ZCTA Crosswalks",
"script": "scripts/datasources/census/load_place_crosswalks.py",
"supports_truncate": True,
"supports_dry_run": True,
"tables": [
"bronze.bronze_jurisdictions_place_county",
"bronze.bronze_jurisdictions_place_zcta",
],
},
]
# ---------------------------------------------------------------------------
# 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]:
"""
Return (failed_keys, source_run_id) from a previous run's results.json.
If run_id is None, uses the most recent run directory.
Raises FileNotFoundError / ValueError with a descriptive message on bad input.
"""
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"]
# ---------------------------------------------------------------------------
# Database helpers
# ---------------------------------------------------------------------------
def count_rows(tables: list[str]) -> dict[str, int | None]:
counts: dict[str, int | None] = {}
try:
conn = psycopg2.connect(DATABASE_URL)
cur = conn.cursor()
for table in tables:
try:
cur.execute(f"SELECT COUNT(*) FROM {table}")
counts[table] = cur.fetchone()[0]
except Exception:
conn.rollback()
counts[table] = None
cur.close()
conn.close()
except Exception:
counts = {t: None for t in tables}
return counts
def total_count(counts: dict[str, int | None]) -> int | None:
vals = [v for v in counts.values() if v is not None]
return sum(vals) if vals else None
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# Loaders
# ---------------------------------------------------------------------------
def run_loader(loader: dict, truncate: bool, dry_run: bool, log_dir: Path) -> dict:
tables = loader.get("tables", [])
before = count_rows(tables)
cmd = [sys.executable, loader["script"]] + list(loader.get("extra_args", []))
if truncate and loader["supports_truncate"]:
cmd.append("--truncate")
if dry_run and loader["supports_dry_run"]:
cmd.append("--dry-run")
log_path = log_dir / f"{loader['key']}.log"
logger.info(f"$ {' '.join(str(c) for c in cmd)}")
logger.info(f" log → {log_path.relative_to(PROJECT_ROOT)}")
start = time.monotonic()
exit_code = run_subprocess(cmd, log_path)
elapsed = time.monotonic() - start
after = count_rows(tables)
return {
"key": loader["key"],
"label": loader["label"],
"exit_code": exit_code,
"elapsed": elapsed,
"ok": exit_code == 0,
"truncated": truncate and loader["supports_truncate"],
"dry_run": dry_run and loader["supports_dry_run"],
"tables": tables,
"before": before,
"after": after,
"log_path": log_path,
}
def ensure_dbt_profiles() -> bool:
if not DBT_PROJECT_DIR.exists():
return False
profiles = DBT_PROJECT_DIR / "profiles.yml"
example = DBT_PROJECT_DIR / "profiles.yml.example"
if not profiles.exists():
if example.exists():
import shutil
shutil.copy(example, profiles)
logger.info("Created dbt profiles.yml from example (first-time setup)")
else:
logger.warning("dbt profiles.yml and profiles.yml.example both missing")
return False
return True
def run_dbt_bronze(select: str, log_dir: Path) -> dict:
label = f"dbt run --select {select}"
log_path = log_dir / "dbt.log"
if not DBT_PROJECT_DIR.exists():
logger.warning(f"dbt_project/ not found at {DBT_PROJECT_DIR} — skipping")
return {
"key": "dbt", "label": label,
"exit_code": -1, "elapsed": 0.0,
"ok": False, "skipped": True,
"tables": [], "log_path": None,
}
ensure_dbt_profiles()
dbt_bin = PROJECT_ROOT / ".venv" / "bin" / "dbt"
dbt_cmd = str(dbt_bin) if dbt_bin.exists() else "dbt"
deps_cmd = [dbt_cmd, "deps", "--profiles-dir", str(DBT_PROJECT_DIR)]
logger.info(f"$ {' '.join(deps_cmd)}")
run_subprocess(deps_cmd, log_path, cwd=DBT_PROJECT_DIR)
cmd = [dbt_cmd, "run", "--select", select, "--profiles-dir", str(DBT_PROJECT_DIR)]
logger.info(f"$ {' '.join(cmd)}")
logger.info(f" log → {log_path.relative_to(PROJECT_ROOT)}")
start = time.monotonic()
exit_code = run_subprocess(cmd, log_path, cwd=DBT_PROJECT_DIR)
elapsed = time.monotonic() - start
return {
"key": "dbt", "label": label,
"exit_code": exit_code, "elapsed": elapsed,
"ok": exit_code == 0, "skipped": False,
"tables": [], "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 fmt_count(n: int | None) -> str:
return f"{n:>12,}" if n is not None else f"{'—':>12}"
def fmt_delta(before: int | None, after: int | None) -> str:
if after is None:
return f"{'—':>12}"
delta = after - (before or 0)
sign = "+" if delta >= 0 else ""
return f"{sign}{delta:,}".rjust(12)
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_skipped = sum(1 for r in results if r.get("skipped"))
n_failed = len(results) - n_ok - n_skipped
sep = "=" * 80
thin = "-" * 80
logger.info("")
logger.info(sep)
logger.info(" BRONZE INGESTION 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':<10} {'TIME':>8} {'ADDED':>12} {'TOTAL':>12} LOADER")
logger.info(thin)
for r in results:
if r.get("skipped"):
tag = "SKIP"
elif r["ok"]:
tag = "OK"
else:
tag = f"FAIL({r['exit_code']})"
notes = []
if r.get("truncated"):
notes.append("truncated")
if r.get("dry_run"):
notes.append("dry-run")
label_suffix = f" [{', '.join(notes)}]" if notes else ""
before_total = total_count(r.get("before", {}))
after_total = total_count(r.get("after", {}))
logger.info(
f" {tag:<10} {fmt_elapsed(r['elapsed']):>8}"
f" {fmt_delta(before_total, after_total)}"
f" {fmt_count(after_total)}"
f" {r['label']}{label_suffix}"
)
tables = r.get("tables", [])
if len(tables) > 1 and r["ok"]:
before_map = r.get("before", {})
after_map = r.get("after", {})
for tbl in tables:
b = before_map.get(tbl)
a = after_map.get(tbl)
short = tbl.split(".")[-1].replace("bronze_jurisdictions_", "")
logger.info(
f" {'':10} {'':8}"
f" {fmt_delta(b, a)}"
f" {fmt_count(a)}"
f" └ {short}"
)
log_path = r.get("log_path")
if log_path:
rel = log_path.relative_to(PROJECT_ROOT)
if not r["ok"] and not r.get("skipped"):
logger.error(f" {'':10} {'':8} {'':12} {'':12} log → {rel}")
else:
logger.info(f" {'':10} {'':8} {'':12} {'':12} log → {rel}")
logger.info(thin)
parts = [f"{n_ok} succeeded"]
if n_failed:
parts.append(f"{n_failed} failed")
if n_skipped:
parts.append(f"{n_skipped} skipped")
summary_line = ", ".join(parts)
if n_failed:
logger.error(f" {summary_line}")
logger.info(f" To retry: python scripts/load_bronze.py --retry-failed")
else:
logger.success(f" {summary_line}")
logger.info(sep)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(
description="Run all bronze data loaders then dbt bronze models",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
keys = [l["key"] for l in LOADERS]
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_082303)",
)
filter_group = parser.add_mutually_exclusive_group()
filter_group.add_argument(
"--skip", nargs="+", choices=keys, metavar="KEY",
help=f"Loaders to skip. Choices: {', '.join(keys)}",
)
filter_group.add_argument(
"--only", nargs="+", choices=keys, metavar="KEY",
help="Run only these loaders",
)
parser.add_argument(
"--truncate", action="store_true",
help="Pass --truncate to loaders that support it",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Pass --dry-run to loaders that support it (no DB writes)",
)
parser.add_argument(
"--no-dbt", action="store_true",
help="Skip the dbt bronze run at the end",
)
parser.add_argument(
"--dbt-select", default="bronze", metavar="SELECTOR",
help="dbt node selector (default: 'bronze')",
)
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 INGESTION")
logger.info(f" {started_at.strftime('%Y-%m-%d %H:%M:%S')} (run: {run_id}) machine: {MACHINE_ID}")
logger.info("=" * 80)
# Determine which loaders to run (retry takes priority over --skip/--only)
active_keys = retry_only or None
loaders = list(LOADERS)
if active_keys is not None:
loaders = [l for l in loaders if l["key"] in active_keys]
elif args.only:
loaders = [l for l in loaders if l["key"] in args.only]
elif args.skip:
loaders = [l for l in loaders if l["key"] not in args.skip]
run_dbt = "dbt" in (active_keys or []) or (
not args.no_dbt and not args.dry_run and active_keys is None
)
if args.truncate:
logger.warning("--truncate active: supported tables will be cleared before loading")
if args.dry_run:
logger.warning("--dry-run active: no data will be written to the database")
results: list[dict] = []
def checkpoint() -> None:
save_results(results, log_dir, started_at)
sync_logs(log_dir, run_type="load_bronze", project_root=PROJECT_ROOT)
for loader in loaders:
logger.info("")
logger.info(f" {'─' * 76}")
logger.info(f" ▶ {loader['label']}")
logger.info(f" {'─' * 76}")
result = run_loader(loader, truncate=args.truncate, dry_run=args.dry_run, log_dir=log_dir)
results.append(result)
if not result["ok"]:
logger.error(f" Loader exited with code {result['exit_code']} — continuing to next step")
checkpoint()
if run_dbt:
logger.info("")
logger.info(f" {'─' * 76}")
logger.info(f" ▶ dbt run --select {args.dbt_select}")
logger.info(f" {'─' * 76}")
results.append(run_dbt_bronze(select=args.dbt_select, log_dir=log_dir))
checkpoint()
print_summary(results, started_at, log_dir)
failed = [r for r in results if not r["ok"] and not r.get("skipped")]
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())