fix(perf): compare_responses — устойчивый dump, не-JSON тела, strip_response без result

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:30:49 +04:00
parent b3899cf6dc
commit 88c37f97fa
2 changed files with 72 additions and 16 deletions

View File

@ -65,8 +65,9 @@ def strip_nulls(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 []]
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
@ -97,8 +98,8 @@ def first_diff(a, b, path: str = "$") -> str | 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 _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]]:
@ -107,14 +108,22 @@ def compare(before: Path, after: Path, mode: str) -> tuple[int, list[str]]:
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")
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
b, a = _load(before, name), _load(after, name)
if mode == "strip-nulls" and is_stripped(name):
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:
@ -124,22 +133,29 @@ def compare(before: Path, after: Path, mode: str) -> tuple[int, list[str]]:
return ok, bad
def dump(man: dict, api: str, out: Path) -> None:
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)
with httpx.Client(base_url=api, timeout=300) as c:
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:
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))
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:
@ -156,7 +172,8 @@ def main(argv=None) -> int:
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))
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)}")

View File

@ -92,3 +92,42 @@ def test_build_requests_minimal_manifest():
assert {"projects-plan-summary", "projects-fact-summary", "projects-detail-summary"} <= set(by)
assert not any("rollup" in n for n in by)
assert cr.is_stripped("sheet-x") and cr.is_stripped("svod2-GO") and not cr.is_stripped("report-1")
def test_strip_response_without_result():
assert cr.strip_response({"success": False}) == {"success": False}
assert cr.strip_response({"result": None}) == {"result": None}
def test_compare_non_json_bodies(tmp_path):
b, a = tmp_path / "before", tmp_path / "after"
for name, bb, ab in (("svod1-A", b"<html>502", b"<html>502"), ("svod1-B", b"<html>502", b"<html>503"),
("svod1-C", b"<html>", b'{"result": []}')):
for d, raw in ((b, bb), (a, ab)):
d.mkdir(exist_ok=True)
(d / f"{name}.json.gz").write_bytes(gzip.compress(raw))
for d in (b, a):
(d / "index.json").write_text(json.dumps({"svod1-A": 502, "svod1-B": 502, "svod1-C": 502}))
n, bad = cr.compare(b, a, "strip-nulls")
assert n == 1 and len(bad) == 2
def test_dump_survives_network_error(tmp_path):
import httpx
def handler(req: httpx.Request) -> httpx.Response:
if req.url.path == "/api/v1/auth/login":
return httpx.Response(200, json={"access_token": "t"})
if req.url.path.startswith("/api/v1/svod/1/MAIN"):
raise httpx.ConnectError("boom", request=req)
return httpx.Response(200, json={"result": []})
man = {"users": {"rf": {"username": "u", "password": "p"}, "dfip": {"username": "u", "password": "p"}},
"sheets": [{"form_id": 1, "form_type": "FORM_2", "sheet": "AHR", "direction": None, "year": 2026,
"url": "", "rows": 1, "owner": "rf"}], "project_reports": []}
with httpx.Client(base_url="http://x", transport=httpx.MockTransport(handler)) as c:
idx = cr.dump(man, c, tmp_path / "o")
assert idx["svod1-MAIN"] == "error: ConnectError"
assert idx["sheet-FORM_2-AHR-none-1"] == 200
assert json.loads((tmp_path / "o" / "index.json").read_text()) == idx
assert not (tmp_path / "o" / "svod1-MAIN.json.gz").exists()