pipeline_miami/discover.py
Served verbatim from the project repository. Internal working document conventions apply: documents may reference file paths, branch names, and findings-ledger anchors from the repo.
"""First-fetch / discovery for Miami-Dade sources.
Reads docs/cities/miami/sources.yaml, fetches each source whose URL has been
resolved past TBD, records provenance (sha256 + retrieved_at + bytes + url +
fetched_from) to data/cities/miami/sources/<source_id>/<date>/, and emits a
per-source findings markdown documenting the sheets / columns / sample rows.
Miami-specific rules (see README.md): www.fldoe.org is rewritten to
origin.fldoe.org for the actual request; the MSID download primes a session
cookie first. Otherwise identical in spirit to pipeline_philly/discover.py.
Usage:
python -m pipeline_miami.discover # list
python -m pipeline_miami.discover --source <source_id>
python -m pipeline_miami.discover --all
python -m pipeline_miami.discover --source <id> --force # re-fetch today even if present
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import pathlib
import re
import sys
from typing import Any
import requests
import yaml
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
SOURCES_YAML = REPO_ROOT / "docs" / "cities" / "miami" / "sources.yaml"
DATA_DIR = REPO_ROOT / "data" / "cities" / "miami" / "sources"
FINDINGS_DIR = REPO_ROOT / "pipeline_miami" / "findings"
UA = "Mozilla/5.0 (X11; Linux x86_64) edu-data/miami-discover"
FLDOE_WWW = "https://www.fldoe.org/"
FLDOE_ORIGIN = "https://origin.fldoe.org/"
MSID_INDEX = "https://eds.fldoe.org/EDS/MasterSchoolID/index.cfm"
def load_sources() -> list[dict[str, Any]]:
with open(SOURCES_YAML) as f:
return yaml.safe_load(f)
def is_resolved(src: dict[str, Any]) -> bool:
url = src.get("url")
return bool(url and url != "TBD")
def fetch_url_for(url: str) -> str:
"""The URL actually requested: the FLDOE origin host, else the URL itself."""
if url.startswith(FLDOE_WWW):
return FLDOE_ORIGIN + url[len(FLDOE_WWW):]
return url
def filename_for(src: dict[str, Any]) -> str:
url = src["url"]
fname = url.rsplit("/", 1)[-1].split("?")[0]
# MSID download and REST queries have no useful last segment.
if not fname or "." not in fname or fname.endswith(".cfm"):
ext = {"txt": "txt", "json": "json", "csv": "csv"}.get(src.get("format", ""), src.get("format", "bin"))
fname = f"{src['source_id']}.{ext}"
return fname
def fetch(src: dict[str, Any], force: bool = False) -> pathlib.Path:
sid = src["source_id"]
url = src["url"]
today = dt.date.today().isoformat()
out_dir = DATA_DIR / sid / today
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / filename_for(src)
prov_path = out_dir / "provenance.json"
if prov_path.exists() and not force:
# Already fetched today (or placed here with provenance by hand):
# re-use it, but still (re)inspect so findings stay current.
existing = [f for f in out_dir.iterdir() if f.name != "provenance.json"]
if existing:
print(f"[reuse] {sid}: {existing[0].name} (fetched earlier; --force to re-fetch)", file=sys.stderr)
return existing[0]
real_url = fetch_url_for(url)
print(f"[fetch] {sid} -> {real_url}", file=sys.stderr)
session = requests.Session()
session.headers["User-Agent"] = UA
if "MasterSchoolID" in url:
session.get(MSID_INDEX, timeout=60) # primes the ColdFusion session cookie
r = session.get(real_url, timeout=300, stream=True)
r.raise_for_status()
h = hashlib.sha256()
n_bytes = 0
with open(path, "wb") as f:
for chunk in r.iter_content(chunk_size=1 << 16):
if not chunk:
continue
f.write(chunk)
h.update(chunk)
n_bytes += len(chunk)
sha = h.hexdigest()
print(f"[fetch] wrote {path} ({n_bytes:,} bytes, sha256={sha[:12]}...)", file=sys.stderr)
provenance: dict[str, Any] = {
"source_id": sid,
"url": url,
"retrieved_at": dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"),
"sha256": sha,
"bytes": n_bytes,
"publisher": src.get("publisher"),
"format": src.get("format"),
}
if real_url != url:
provenance["fetched_from"] = real_url
if src.get("notes"):
provenance["notes"] = str(src["notes"]).strip()
prov_path.write_text(json.dumps(provenance, indent=2))
return path
# ── inspectors ───────────────────────────────────────────────────────────────
def _find_header_row(raw, must: tuple[str, ...] = ("School",)) -> int | None:
for i in range(min(12, len(raw))):
row = [str(v) for v in raw.iloc[i].tolist()]
if any(any(m.lower() in c.lower() for c in row) for m in must):
return i
return None
def inspect_excel(path: pathlib.Path) -> dict[str, Any]:
"""Sheets + detected header row + columns + first data row; xls via xlrd."""
import pandas as pd
out: dict[str, Any] = {"format": "excel", "sheets": {}}
xf = pd.ExcelFile(path)
out["sheet_names"] = list(xf.sheet_names)
for sheet in xf.sheet_names[:8]:
try:
raw = pd.read_excel(xf, sheet_name=sheet, header=None, nrows=15)
except Exception as e: # noqa: BLE001
out["sheets"][sheet] = {"error": str(e)}
continue
h = _find_header_row(raw)
entry: dict[str, Any] = {"header_row_1based": None if h is None else h + 1}
if h is None:
entry["first_rows"] = [[None if pd.isna(v) else str(v)[:60] for v in raw.iloc[i].tolist()[:12]] for i in range(min(4, len(raw)))]
else:
df = pd.read_excel(xf, sheet_name=sheet, header=h, nrows=5)
entry["n_columns"] = len(df.columns)
entry["columns"] = [str(c) for c in df.columns]
entry["sample_row"] = (
{str(k): (None if pd.isna(v) else str(v)) for k, v in df.iloc[0].items()} if len(df) > 0 else None
)
out["sheets"][sheet] = entry
return out
def inspect_csv(path: pathlib.Path, sep: str = ",") -> dict[str, Any]:
import pandas as pd
out: dict[str, Any] = {"format": "csv"}
df = pd.read_csv(path, nrows=5, low_memory=False, sep=sep, dtype=str, encoding="latin1")
with open(path, "rb") as f:
out["n_rows"] = sum(1 for _ in f) - 1
out["n_columns"] = len(df.columns)
out["columns"] = [str(c) for c in df.columns]
out["sample_row"] = (
{str(k): (None if pd.isna(v) else str(v)) for k, v in df.iloc[0].items()} if len(df) > 0 else None
)
return out
def inspect_pdf(path: pathlib.Path) -> dict[str, Any]:
import pdfplumber
out: dict[str, Any] = {"format": "pdf"}
with pdfplumber.open(path) as pdf:
out["n_pages"] = len(pdf.pages)
out["page1_text"] = (pdf.pages[0].extract_text() or "")[:1500]
return out
def inspect_json(path: pathlib.Path) -> dict[str, Any]:
d = json.loads(path.read_text())
out: dict[str, Any] = {"format": "json"}
if isinstance(d, dict) and "features" in d: # ArcGIS feature set
feats = d["features"]
out["n_features"] = len(feats)
out["fields"] = [f["name"] for f in d.get("fields", [])]
out["sample_attributes"] = feats[0]["attributes"] if feats else None
else:
out["top_level_keys"] = list(d.keys())[:40] if isinstance(d, dict) else f"list[{len(d)}]"
return out
def inspect_zip(path: pathlib.Path) -> dict[str, Any]:
import zipfile
out: dict[str, Any] = {"format": "zip", "entries": []}
with zipfile.ZipFile(path) as z:
for info in z.infolist()[:30]:
out["entries"].append({"name": info.filename, "bytes": info.file_size})
out["entry_count"] = len(z.namelist())
return out
def inspect(path: pathlib.Path, fmt: str | None) -> dict[str, Any]:
suffix = path.suffix.lower()
if fmt in ("xlsx", "xls") or suffix in (".xlsx", ".xls", ".xlsm"):
return inspect_excel(path)
if fmt == "zip" or suffix == ".zip":
return inspect_zip(path)
if fmt == "csv" or suffix == ".csv":
return inspect_csv(path)
if fmt == "txt" or suffix == ".txt":
return inspect_csv(path, sep="\t")
if fmt == "pdf" or suffix == ".pdf":
return inspect_pdf(path)
if fmt == "json" or suffix == ".json":
return inspect_json(path)
return {"format": fmt or suffix, "note": "no inspector for format"}
def render_findings(src: dict[str, Any], path: pathlib.Path, ins: dict[str, Any]) -> str:
sid = src["source_id"]
out: list[str] = [f"# {sid} — first-fetch findings\n"]
out += [f"- publisher: {src.get('publisher')}", f"- url: {src.get('url')}", f"- format: {src.get('format')}",
f"- local path: `{path.relative_to(REPO_ROOT)}`", ""]
fmt = ins.get("format")
if fmt == "excel":
out.append(f"## Sheets ({len(ins.get('sheet_names', []))})")
out += [f"- `{n}`" for n in ins.get("sheet_names", [])]
out.append("")
for name, sheet in ins.get("sheets", {}).items():
out.append(f"### `{name}` (header row {sheet.get('header_row_1based')})")
if sheet.get("columns"):
out += [f"- `{c}`" for c in sheet["columns"]]
if sheet.get("first_rows"):
out.append("No header detected in the first 15 rows; first rows:")
out.append("```json\n" + json.dumps(sheet["first_rows"], indent=1) + "\n```")
if sheet.get("sample_row"):
out.append("\nSample first row:\n\n```json\n" + json.dumps(sheet["sample_row"], indent=2) + "\n```")
out.append("")
elif fmt == "csv":
out.append(f"## Table — {ins.get('n_rows', '?'):,} rows × {ins.get('n_columns', '?')} columns\n")
out += [f"- `{c}`" for c in ins.get("columns", [])]
if ins.get("sample_row"):
out.append("\nSample first row:\n\n```json\n" + json.dumps(ins["sample_row"], indent=2) + "\n```")
elif fmt == "pdf":
out.append(f"## PDF — {ins.get('n_pages')} pages\n\nPage 1 text:\n\n```\n{ins.get('page1_text')}\n```")
elif fmt == "json":
out.append("## JSON\n\n```json\n" + json.dumps({k: v for k, v in ins.items() if k != 'format'}, indent=2)[:4000] + "\n```")
elif fmt == "zip":
out.append(f"## Archive contents ({ins.get('entry_count')} entries)")
out += [f"- `{e['name']}` ({e['bytes']:,} bytes)" for e in ins.get("entries", [])]
return "\n".join(out)
def discover_one(src: dict[str, Any], force: bool = False) -> None:
sid = src["source_id"]
if not is_resolved(src):
print(f"[skip] {sid}: url is TBD", file=sys.stderr)
return
try:
path = fetch(src, force=force)
ins = inspect(path, src.get("format"))
FINDINGS_DIR.mkdir(parents=True, exist_ok=True)
(FINDINGS_DIR / f"{sid}.md").write_text(render_findings(src, path, ins))
print(f"[findings] -> pipeline_miami/findings/{sid}.md", file=sys.stderr)
except Exception as e: # noqa: BLE001
print(f"[error] {sid}: {type(e).__name__}: {e}", file=sys.stderr)
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("--source", default=None)
p.add_argument("--all", action="store_true")
p.add_argument("--force", action="store_true")
args = p.parse_args()
sources = load_sources()
if args.source:
match = [s for s in sources if s["source_id"] == args.source]
if not match:
print(f"no such source: {args.source}", file=sys.stderr)
sys.exit(1)
for s in match:
discover_one(s, force=args.force)
elif args.all:
for s in sources:
discover_one(s, force=args.force)
else:
for s in sources:
fetched = any((DATA_DIR / s["source_id"]).glob("*/provenance.json")) if (DATA_DIR / s["source_id"]).exists() else False
mark = "✓" if fetched else ("·" if is_resolved(s) else "?")
print(f" {mark} {s['source_id']:48s} {s.get('publisher', '-')}")
if __name__ == "__main__":
main()