187 lines
8.3 KiB
Python
187 lines
8.3 KiB
Python
"""Сравнение ответов тяжёлых 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), остальные ответы сравниваются точно.
|
||
Снимок: <out>/<имя>.json.gz (распакованное тело) и <out>/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())
|