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

170 lines
7.4 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)
out["result"] = [{**row, "data": strip_nulls(row["data"])} if isinstance(row, dict) and "data" in row else row
for row in body.get("result") or []]
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 _load(d: Path, name: str):
return json.loads(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():
bad.append(f"{name}: нет в after")
continue
if st != ai[name]:
bad.append(f"{name}: статус {st} != {ai[name]}")
continue
b, a = _load(before, name), _load(after, name)
if mode == "strip-nulls" and is_stripped(name):
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, api: str, out: Path) -> None:
out.mkdir(parents=True, exist_ok=True)
index, tokens = {}, {}
reqs = build_requests(man)
with httpx.Client(base_url=api, timeout=300) as c:
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:
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)
(out / "index.json").write_text(json.dumps(index, ensure_ascii=False, indent=1))
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())
dump(man, a.api, 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())