|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Diff two AWS pricing snapshots and write an impossible-to-miss change report. |
| 3 | +
|
| 4 | +Compares an OLD pricing_<region>.json against a NEW one, computes the % change of every price, and |
| 5 | +writes a Markdown report (PRICING_CHANGES.md) plus a machine-readable summary. Any price that moves |
| 6 | +more than --threshold percent (default 1%) is a **SPIKE** and is shouted at the top of the report |
| 7 | +with 🚨 banners and giant headers so it's impossible to miss in a PR. Standard library only. |
| 8 | +
|
| 9 | +Used by the weekly "AWS pricing review" job (GitHub Action or a Jules scheduled task): fetch fresh |
| 10 | +prices, diff against the committed snapshot, and open a PR whose body is this report. |
| 11 | +
|
| 12 | + python aws-pricing/pricing_diff.py --old <snapshot.json> --new <fresh.json> \ |
| 13 | + --out-md PRICING_CHANGES.md --out-summary summary.json [--threshold 1.0] [--demo-spike] |
| 14 | +
|
| 15 | +--demo-spike injects a synthetic +7.3% move into the NEW data so you can test the alert formatting |
| 16 | +without waiting for a real price change. |
| 17 | +""" |
| 18 | +import argparse |
| 19 | +import json |
| 20 | +import sys |
| 21 | + |
| 22 | + |
| 23 | +def load(path): |
| 24 | + with open(path, encoding="utf-8") as fh: |
| 25 | + return json.load(fh) |
| 26 | + |
| 27 | + |
| 28 | +def flatten(doc): |
| 29 | + """A pricing doc -> {human label: price} for every comparable price in it.""" |
| 30 | + out = {} |
| 31 | + for t, i in doc.get("ec2", {}).get("instances", {}).items(): |
| 32 | + out[f"EC2 · {t}"] = i.get("hourlyUSD") |
| 33 | + for t, v in doc.get("ebs", {}).items(): |
| 34 | + out[f"EBS · {t} (GB-mo)"] = v |
| 35 | + s = doc.get("s3", {}) |
| 36 | + if "standardStorageUsdPerGBMonth" in s: |
| 37 | + out["S3 · Standard storage (GB-mo)"] = s["standardStorageUsdPerGBMonth"] |
| 38 | + out["S3 · PUT (per 1k)"] = s.get("putPer1k") |
| 39 | + out["S3 · GET (per 1k)"] = s.get("getPer1k") |
| 40 | + for _, i in doc.get("rds", {}).get("instances", {}).items(): |
| 41 | + out[f"RDS · {i.get('engine')} {i.get('instanceType')}"] = i.get("hourlyUSD") |
| 42 | + for t, v in doc.get("rds", {}).get("storage", {}).items(): |
| 43 | + out[f"RDS storage · {t} (GB-mo)"] = v |
| 44 | + lam = doc.get("lambda", {}) |
| 45 | + if "requestUsd" in lam: |
| 46 | + out["Lambda · request"] = lam["requestUsd"] |
| 47 | + out["Lambda · GB-second"] = lam.get("gbSecondUsd") |
| 48 | + return {k: v for k, v in out.items() if isinstance(v, (int, float))} |
| 49 | + |
| 50 | + |
| 51 | +def fmt(v): |
| 52 | + """Money-ish formatting that keeps precision for tiny per-request rates.""" |
| 53 | + if v is None: |
| 54 | + return "—" |
| 55 | + if v == 0: |
| 56 | + return "$0" |
| 57 | + if v >= 1: |
| 58 | + return f"${v:,.2f}" |
| 59 | + if v >= 0.01: |
| 60 | + return f"${v:.4f}".rstrip("0").rstrip(".") |
| 61 | + return f"${v:.3g}" |
| 62 | + |
| 63 | + |
| 64 | +def diff(old, new, threshold): |
| 65 | + """Return (spikes, minor, added, removed) lists, each sorted by |%| descending.""" |
| 66 | + fo, fn = flatten(old), flatten(new) |
| 67 | + spikes, minor, added, removed = [], [], [], [] |
| 68 | + for key in sorted(set(fo) | set(fn)): |
| 69 | + o, n = fo.get(key), fn.get(key) |
| 70 | + if o is None: |
| 71 | + added.append((key, n)) |
| 72 | + continue |
| 73 | + if n is None: |
| 74 | + removed.append((key, o)) |
| 75 | + continue |
| 76 | + if o == n: |
| 77 | + continue |
| 78 | + pct = ((n - o) / o * 100) if o else float("inf") |
| 79 | + rec = {"key": key, "old": o, "new": n, "pct": pct} |
| 80 | + (spikes if abs(pct) > threshold else minor).append(rec) |
| 81 | + spikes.sort(key=lambda r: abs(r["pct"]), reverse=True) |
| 82 | + minor.sort(key=lambda r: abs(r["pct"]), reverse=True) |
| 83 | + return spikes, minor, added, removed |
| 84 | + |
| 85 | + |
| 86 | +def arrow(pct): |
| 87 | + return "🔺" if pct > 0 else "🔻" |
| 88 | + |
| 89 | + |
| 90 | +def row(r): |
| 91 | + return f"| {arrow(r['pct'])} | **{r['key']}** | {fmt(r['old'])} | {fmt(r['new'])} | **{r['pct']:+.2f}%** |" |
| 92 | + |
| 93 | + |
| 94 | +def build_md(old, new, spikes, minor, added, removed, threshold): |
| 95 | + region = new.get("label", new.get("region", "?")) |
| 96 | + o_gen = (old.get("generated") or "?")[:10] |
| 97 | + n_gen = (new.get("generated") or "?")[:10] |
| 98 | + L = [] |
| 99 | + if spikes: |
| 100 | + top = spikes[0] |
| 101 | + L += [ |
| 102 | + "# 🚨🚨🚨 AWS PRICE SPIKE ALERT 🚨🚨🚨", |
| 103 | + "", |
| 104 | + f"## ‼️ {len(spikes)} price change(s) exceed the {threshold:g}% threshold — REVIEW BEFORE MERGING ‼️", |
| 105 | + "", |
| 106 | + f"> # {arrow(top['pct'])} BIGGEST MOVE: {top['key']} **{top['pct']:+.2f}%**", |
| 107 | + f"> ## {fmt(top['old'])} → {fmt(top['new'])}", |
| 108 | + "", |
| 109 | + "| | Item | Old | New | Change |", |
| 110 | + "|:--:|---|---:|---:|:--:|", |
| 111 | + *[row(r) for r in spikes], |
| 112 | + "", |
| 113 | + "---", |
| 114 | + "", |
| 115 | + ] |
| 116 | + elif minor or added or removed: |
| 117 | + L += [f"# ✅ Weekly AWS pricing update — no spikes over {threshold:g}%", ""] |
| 118 | + else: |
| 119 | + L += ["# ✅ Weekly AWS pricing update — no changes", ""] |
| 120 | + |
| 121 | + L += [f"**Region:** {region} · **Data:** {o_gen} → {n_gen}", ""] |
| 122 | + |
| 123 | + if minor: |
| 124 | + L += [ |
| 125 | + f"<details{' open' if not spikes else ''}><summary>Other changes under {threshold:g}% " |
| 126 | + f"({len(minor)})</summary>", |
| 127 | + "", |
| 128 | + "| | Item | Old | New | Change |", |
| 129 | + "|:--:|---|---:|---:|:--:|", |
| 130 | + *[row(r) for r in minor], |
| 131 | + "", |
| 132 | + "</details>", |
| 133 | + "", |
| 134 | + ] |
| 135 | + if added: |
| 136 | + L += ["**Newly listed:** " + ", ".join(f"{k} ({fmt(v)})" for k, v in added), ""] |
| 137 | + if removed: |
| 138 | + L += ["**No longer listed:** " + ", ".join(f"{k} (was {fmt(v)})" for k, v in removed), ""] |
| 139 | + |
| 140 | + L += [ |
| 141 | + "---", |
| 142 | + "_On-Demand list prices from AWS's public Price List Bulk API. Approximate — exclude Free " |
| 143 | + "Tier, Savings Plans/RIs, discounts, and taxes. Generated by `aws-pricing/pricing_diff.py`._", |
| 144 | + ] |
| 145 | + return "\n".join(L) + "\n" |
| 146 | + |
| 147 | + |
| 148 | +def title(spikes, minor, added, removed, n_gen): |
| 149 | + date = (n_gen or "")[:10] |
| 150 | + if spikes: |
| 151 | + t = spikes[0] |
| 152 | + return f"🚨 AWS pricing SPIKE {t['pct']:+.1f}% ({t['key']}) — weekly review {date}" |
| 153 | + if minor or added or removed: |
| 154 | + return f"AWS weekly pricing update — {date} (no spikes)" |
| 155 | + return f"AWS weekly pricing — no changes {date}" |
| 156 | + |
| 157 | + |
| 158 | +def demo_spike(new): |
| 159 | + """Inject a synthetic +7.3% EC2 move + a -4.2% Lambda move so the alert can be tested.""" |
| 160 | + inst = new.get("ec2", {}).get("instances", {}) |
| 161 | + if inst: |
| 162 | + k = sorted(inst)[0] |
| 163 | + inst[k]["hourlyUSD"] = round(inst[k]["hourlyUSD"] * 1.073, 6) |
| 164 | + lam = new.get("lambda", {}) |
| 165 | + if "gbSecondUsd" in lam: |
| 166 | + lam["gbSecondUsd"] = round(lam["gbSecondUsd"] * 0.958, 10) |
| 167 | + return new |
| 168 | + |
| 169 | + |
| 170 | +def main(): |
| 171 | + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| 172 | + ap.add_argument("--old", required=True, help="previous pricing_<region>.json (the committed snapshot)") |
| 173 | + ap.add_argument("--new", required=True, help="freshly generated pricing_<region>.json") |
| 174 | + ap.add_argument("--threshold", type=float, default=1.0, help="spike threshold in %% (default 1.0)") |
| 175 | + ap.add_argument("--out-md", default="PRICING_CHANGES.md") |
| 176 | + ap.add_argument("--out-summary", default="pricing_changes_summary.json") |
| 177 | + ap.add_argument("--demo-spike", action="store_true", help="inject a synthetic spike into --new (testing)") |
| 178 | + args = ap.parse_args() |
| 179 | + |
| 180 | + old, new = load(args.old), load(args.new) |
| 181 | + if args.demo_spike: |
| 182 | + new = demo_spike(new) |
| 183 | + |
| 184 | + spikes, minor, added, removed = diff(old, new, args.threshold) |
| 185 | + md = build_md(old, new, spikes, minor, added, removed, args.threshold) |
| 186 | + with open(args.out_md, "w", encoding="utf-8") as fh: |
| 187 | + fh.write(md) |
| 188 | + |
| 189 | + total = len(spikes) + len(minor) + len(added) + len(removed) |
| 190 | + top = spikes[0] if spikes else None |
| 191 | + summary = { |
| 192 | + "changed": total > 0, |
| 193 | + "spikes": len(spikes), |
| 194 | + "changes": total, |
| 195 | + "threshold": args.threshold, |
| 196 | + "max_pct": round(top["pct"], 3) if top else 0.0, |
| 197 | + "max_item": top["key"] if top else "", |
| 198 | + "title": title(spikes, minor, added, removed, new.get("generated", "")), |
| 199 | + } |
| 200 | + with open(args.out_summary, "w", encoding="utf-8") as fh: |
| 201 | + json.dump(summary, fh, indent=2) |
| 202 | + |
| 203 | + print(f"pricing-diff: {len(spikes)} spike(s) >{args.threshold:g}%, {len(minor)} minor, " |
| 204 | + f"{len(added)} added, {len(removed)} removed", file=sys.stderr) |
| 205 | + if top: |
| 206 | + print(f" biggest: {top['key']} {top['pct']:+.2f}% ({fmt(top['old'])} → {fmt(top['new'])})", file=sys.stderr) |
| 207 | + |
| 208 | + |
| 209 | +if __name__ == "__main__": |
| 210 | + main() |
0 commit comments