#!/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//. 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())