"""Сравнение ответов тяжёлых GET до и после задач A и B (AURORA-1848). Порядок использования (D7.1): 1. `dump` на базовом коммите (до A): python analysis/compare_responses.py dump --scale 1 --out /path/base 2. `dump` на коммите A: ... dump --scale 1 --out /path/a 3. `compare /path/base /path/a --mode exact` - A не меняет ответы ни на байт значения 4. `dump` на коммите B: ... dump --scale 1 --out /path/b 5. `compare /path/base /path/b --mode strip-nulls` - B: листы и своды = «до» с вырезанными null в data (семантика jsonb_strip_nulls PG 15), остальные ответы сравниваются точно. Снимок: /<имя>.json.gz (распакованное тело) и /index.json ({имя: статус}). Код возврата compare: 1 при расхождении. Стенд запускает человек; тесты работают на tmp_path и MockTransport. """ from __future__ import annotations import argparse import gzip import json import sys from pathlib import Path import httpx PERF = Path(__file__).resolve().parents[1] def build_requests(man: dict) -> list[tuple[str, str, str]]: """Список (роль, имя, путь с query) по манифесту.""" out: list[tuple[str, str, str]] = [] sheets = man.get("sheets", []) for s in sheets: role = "rf" if s.get("owner", "rf") == "rf" else "dfip" d = s.get("direction") path = f"/api/v1/form/{s['form_id']}/sheet/{s['sheet']}" + (f"?direction={d}" if d is not None else "") name = f"sheet-{s['form_type']}-{s['sheet']}-{d or 'none'}-{s['form_id']}" out.append((role, name, path)) if s["form_type"] == "FORM_2" and s["sheet"] == "AHR" and role == "rf": sep = "&" if "?" in path else "?" out.append((role, f"{name}-sections", f"{path}{sep}sections=plan,q1")) f2 = next((s for s in sheets if s["form_type"] == "FORM_2" and s["sheet"] == "AHR" and s.get("owner", "rf") == "rf"), None) year = (f2 or sheets[0])["year"] if sheets else None if year is not None: for k in ("FORM_1", "FORM_2", "FORM_3", "FORM_4", "MAIN"): out.append(("dfip", f"svod1-{k}", f"/api/v1/svod/1/{k}/{year}")) for k in ("GO", "RF"): out.append(("dfip", f"svod2-{k}", f"/api/v1/svod/2/{k}/{year}")) for kind in ("plan", "fact", "detail"): out.append(("dfip", f"projects-{kind}-summary", f"/api/v1/projects/{kind}-summary?year={year}")) for r in man.get("project_reports", []): out.append(("rf", f"report-{r['project_id']}-{r['year']}-{r['report_type']}", f"/api/v1/projects/{r['project_id']}/report/{r['year']}/{r['report_type']}")) for pid in dict.fromkeys(r["project_id"] for r in man.get("project_reports", [])): out.append(("rf", f"project-summary-{pid}", f"/api/v1/projects/{pid}/summary")) return out def strip_nulls(value): """Как jsonb_strip_nulls: убирает None-поля объектов рекурсивно; None в списках и {} остаются.""" if isinstance(value, dict): return {k: strip_nulls(v) for k, v in value.items() if v is not None} if isinstance(value, list): return [strip_nulls(v) for v in value] return value def strip_response(body: dict) -> dict: """Копия тела, где strip_nulls применён только к row['data'] строк result.""" out = dict(body) if isinstance(body.get("result"), list): out["result"] = [{**row, "data": strip_nulls(row["data"])} if isinstance(row, dict) and "data" in row else row for row in body["result"]] return out def is_stripped(name: str) -> bool: return name.startswith(("sheet-", "svod1-", "svod2-")) def first_diff(a, b, path: str = "$") -> str | None: """Путь до первого отличия с обоими значениями; None при равенстве.""" if isinstance(a, dict) and isinstance(b, dict): if set(a) != set(b): return f"{path}: ключи {sorted(set(a) ^ set(b))} есть только в одном из ответов" for k in a: d = first_diff(a[k], b[k], f"{path}.{k}") if d: return d return None if isinstance(a, list) and isinstance(b, list): if len(a) != len(b): return f"{path}: длина {len(a)} != {len(b)}" for i, (x, y) in enumerate(zip(a, b)): d = first_diff(x, y, f"{path}[{i}]") if d: return d return None if type(a) is type(b) and a == b: return None return f"{path}: {a!r} != {b!r}" def _raw(d: Path, name: str) -> bytes: return gzip.decompress((d / f"{name}.json.gz").read_bytes()) def compare(before: Path, after: Path, mode: str) -> tuple[int, list[str]]: bi = json.loads((before / "index.json").read_text()) ai_p = after / "index.json" ai = json.loads(ai_p.read_text()) if ai_p.exists() else {} ok, bad = 0, [] for name, st in bi.items(): if name not in ai or not (after / f"{name}.json.gz").exists() or not (before / f"{name}.json.gz").exists(): bad.append(f"{name}: нет файла в before или after") continue if st != ai[name]: bad.append(f"{name}: статус {st} != {ai[name]}") continue rb, ra = _raw(before, name), _raw(after, name) try: b, a = json.loads(rb), json.loads(ra) except ValueError: if rb == ra: ok += 1 else: bad.append(f"{name}: тела не JSON и различаются ({len(rb)} и {len(ra)} байт)") continue if mode == "strip-nulls" and is_stripped(name) and isinstance(b, dict): b = strip_response(b) d = first_diff(b, a) if d: bad.append(f"{name}: {d}") else: ok += 1 return ok, bad def dump(man: dict, c: httpx.Client, out: Path) -> dict: """Снимок всех запросов через готовый клиент; сбой одного запроса не прерывает дамп, index.json пишется всегда.""" out.mkdir(parents=True, exist_ok=True) index, tokens = {}, {} reqs = build_requests(man) try: for role in sorted({r for r, _, _ in reqs}): u = man["users"][role] r = c.post("/api/v1/auth/login", json={"username": u["username"], "password": u["password"]}) r.raise_for_status() tokens[role] = r.json()["access_token"] for role, name, path in reqs: try: r = c.get(path, headers={"Authorization": f"Bearer {tokens[role]}"}) (out / f"{name}.json.gz").write_bytes(gzip.compress(r.content)) index[name] = r.status_code print(name, r.status_code, len(r.content), flush=True) except httpx.HTTPError as e: index[name] = f"error: {type(e).__name__}" print(name, index[name], flush=True) finally: (out / "index.json").write_text(json.dumps(index, ensure_ascii=False, indent=1)) return index def main(argv=None) -> int: ap = argparse.ArgumentParser() sub = ap.add_subparsers(dest="cmd", required=True) d = sub.add_parser("dump") d.add_argument("--scale", type=int, default=1) d.add_argument("--out", required=True) d.add_argument("--api", default="http://localhost:8000") c = sub.add_parser("compare") c.add_argument("before") c.add_argument("after") c.add_argument("--mode", choices=("exact", "strip-nulls"), default="exact") a = ap.parse_args(argv) if a.cmd == "dump": man = json.loads((PERF / "results" / f"manifest-s{a.scale}.json").read_text()) with httpx.Client(base_url=a.api, timeout=300) as c: dump(man, c, Path(a.out)) return 0 ok, bad = compare(Path(a.before), Path(a.after), a.mode) print(f"совпало {ok} из {ok + len(bad)}") for line in bad: print(line) return 1 if bad else 0 if __name__ == "__main__": sys.exit(main())