DFiP_Budget_planing/perf/analysis/compare_responses.py
2026-10-28 21:30:49 +04:00

187 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сравнение ответов тяжёлых 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())