← back to La Permits School
Add summary.py: class-ready descriptive stats (by year, top contractors, by council district, permit-type mix, biggest projects)
70572990f248ddf9cf01ae92e1fc72ef3566bc53 · 2026-08-10 13:07:49 -0700 · Steve Abrams
Files touched
Diff
commit 70572990f248ddf9cf01ae92e1fc72ef3566bc53
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 10 13:07:49 2026 -0700
Add summary.py: class-ready descriptive stats (by year, top contractors, by council district, permit-type mix, biggest projects)
---
README.md | 8 +++-
summary.py | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 128 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index bdc1b22..8fa0dbb 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,13 @@ python3 pull_permits.py --limit 500 --out sample.csv
```
Example: 1,000 permits → ~290 unique firms; top row = Hensel Phelps ($1.6B / 10
permits). This deduped list IS your "top LA contractors" ranking.
-3. **CA SOS + CSLB** (free lookups) → open the URLs on the firms you care about to
+3. **`summary.py`** → class-ready descriptive stats printed to the terminal
+ (headline totals, permits & $ by year, top contractors, build $ by council
+ district, permit-type mix, biggest single projects):
+ ```bash
+ python3 summary.py --top 8
+ ```
+4. **CA SOS + CSLB** (free lookups) → open the URLs on the firms you care about to
resolve the legal entity + agent (SOS) and license status/classification (CSLB).
### Why the enrichment is a worklist, not an auto-scrape
diff --git a/summary.py b/summary.py
new file mode 100644
index 0000000..de4a547
--- /dev/null
+++ b/summary.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+"""
+summary.py — class-ready descriptive stats from permits.csv (school project). $0, stdlib only.
+
+Prints: headline totals, permits & $ by year, top contractors by build value,
+total build $ by City Council district, permit-type mix, and the biggest single
+projects — all as clean text tables you can paste into a report or slide.
+
+Usage:
+ python3 summary.py # reads permits.csv
+ python3 summary.py --in firms.csv --top 15
+"""
+
+import argparse
+import csv
+from collections import defaultdict
+
+OWNER_PLACEHOLDERS = {"OWNER-BUILDER", "OWNER BUILDER", "OWNER/BUILDER"}
+
+
+def money(v):
+ try:
+ return float(v)
+ except (TypeError, ValueError):
+ return 0.0
+
+
+def usd(n):
+ return "$" + format(int(n), ",")
+
+
+def bar(frac, width=24):
+ return "█" * int(round(frac * width))
+
+
+def table(title, rows, cols):
+ """rows = list of tuples; cols = list of (header, width, align) where align in 'lr'."""
+ print(f"\n{title}")
+ print("-" * len(title))
+ head = " ".join(("{:<%d}" % w if a == "l" else "{:>%d}" % w).format(h) for h, w, a in cols)
+ print(head)
+ for r in rows:
+ print(" ".join(("{:<%d}" % w if a == "l" else "{:>%d}" % w).format(str(c)) for c, (_, w, a) in zip(r, cols)))
+
+
+def main():
+ ap = argparse.ArgumentParser(description="Descriptive stats from a permits CSV.")
+ ap.add_argument("--in", dest="infile", default="permits.csv")
+ ap.add_argument("--top", type=int, default=10, help="rows per ranking (default 10)")
+ args = ap.parse_args()
+
+ rows = list(csv.DictReader(open(args.infile, newline="", encoding="utf-8")))
+ if not rows:
+ print("No rows in", args.infile)
+ return
+
+ total_val = sum(money(r.get("valuation")) for r in rows)
+ dates = sorted(r["issue_date"][:10] for r in rows if r.get("issue_date"))
+
+ by_year = defaultdict(lambda: [0, 0.0]) # year -> [count, $]
+ by_cd = defaultdict(lambda: [0, 0.0]) # council district -> [count, $]
+ by_type = defaultdict(lambda: [0, 0.0]) # permit_type -> [count, $]
+ by_firm = defaultdict(lambda: [0, 0.0]) # contractor -> [count, $]
+ biggest = [] # (val, address, contractor)
+
+ for r in rows:
+ v = money(r.get("valuation"))
+ yr = (r.get("issue_date") or "")[:4]
+ if yr:
+ by_year[yr][0] += 1; by_year[yr][1] += v
+ cd = (r.get("council_district") or "?").strip() or "?"
+ by_cd[cd][0] += 1; by_cd[cd][1] += v
+ pt = (r.get("permit_type") or "?").strip() or "?"
+ by_type[pt][0] += 1; by_type[pt][1] += v
+ name = (r.get("contractors_business_name") or "").strip()
+ if name and name.upper() not in OWNER_PLACEHOLDERS:
+ by_firm[name][0] += 1; by_firm[name][1] += v
+ biggest.append((v, r.get("address", ""), name or "(none)"))
+
+ # ---- headline ----
+ print("=" * 58)
+ print("LA BUILDING PERMITS — SUMMARY (cost: $0, local)")
+ print("=" * 58)
+ print(f" Permits : {len(rows):,}")
+ print(f" Total valuation : {usd(total_val)}")
+ print(f" Avg per permit : {usd(total_val / len(rows))}")
+ print(f" Date range : {dates[0]} -> {dates[-1]}")
+ print(f" Unique contractors: {len(by_firm):,} (excl. owner-builder)")
+
+ # ---- by year ----
+ ymax = max(c for c, _ in by_year.values())
+ table("PERMITS BY YEAR", [
+ (y, f"{c:,}", usd(s), bar(c / ymax)) for y, (c, s) in sorted(by_year.items())
+ ], [("Year", 6, "l"), ("Permits", 9, "r"), ("Valuation", 18, "r"), ("", 24, "l")])
+
+ # ---- top contractors ----
+ top_firms = sorted(by_firm.items(), key=lambda kv: kv[1][1], reverse=True)[:args.top]
+ table(f"TOP {args.top} CONTRACTORS BY BUILD VALUE", [
+ (i + 1, usd(s), f"{c}", name[:38]) for i, (name, (c, s)) in enumerate(top_firms)
+ ], [("#", 3, "r"), ("Total $", 18, "r"), ("Permits", 8, "r"), ("Contractor", 38, "l")])
+
+ # ---- by council district ----
+ top_cd = sorted(by_cd.items(), key=lambda kv: kv[1][1], reverse=True)[:args.top]
+ table(f"TOP {args.top} COUNCIL DISTRICTS BY BUILD VALUE", [
+ (cd, f"{c:,}", usd(s)) for cd, (c, s) in top_cd
+ ], [("District", 9, "l"), ("Permits", 9, "r"), ("Valuation", 18, "r")])
+
+ # ---- permit type mix ----
+ table("PERMIT-TYPE MIX", [
+ (pt[:20], f"{c:,}", usd(s)) for pt, (c, s) in sorted(by_type.items(), key=lambda kv: kv[1][1], reverse=True)
+ ], [("Type", 20, "l"), ("Permits", 9, "r"), ("Valuation", 18, "r")])
+
+ # ---- biggest single projects ----
+ table(f"BIGGEST {args.top} SINGLE PROJECTS", [
+ (usd(v), (a or "?")[:30], c[:30]) for v, a, c in sorted(biggest, reverse=True)[:args.top]
+ ], [("Valuation", 16, "r"), ("Address", 30, "l"), ("Contractor", 30, "l")])
+ print()
+
+
+if __name__ == "__main__":
+ main()
← 4e1bc46 Add enrich_contractors.py: dedupe permits to unique contract
·
back to La Permits School
·
Add FINDINGS.md: written analysis (2020 vs 2023 dip, CD-11/L d130dd7 →