From 88c37f97fad34baf6f1f5df670ea9056ec9073e3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 21:30:49 +0400 Subject: [PATCH] =?UTF-8?q?fix(perf):=20compare=5Fresponses=20=E2=80=94=20?= =?UTF-8?q?=D1=83=D1=81=D1=82=D0=BE=D0=B9=D1=87=D0=B8=D0=B2=D1=8B=D0=B9=20?= =?UTF-8?q?dump,=20=D0=BD=D0=B5-JSON=20=D1=82=D0=B5=D0=BB=D0=B0,=20strip?= =?UTF-8?q?=5Fresponse=20=D0=B1=D0=B5=D0=B7=20result?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- perf/analysis/compare_responses.py | 49 +++++++++++++++++++--------- perf/tests/test_compare_responses.py | 39 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/perf/analysis/compare_responses.py b/perf/analysis/compare_responses.py index 3c8a694..7693c8c 100644 --- a/perf/analysis/compare_responses.py +++ b/perf/analysis/compare_responses.py @@ -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)}") diff --git a/perf/tests/test_compare_responses.py b/perf/tests/test_compare_responses.py index e6644c4..d9a8522 100644 --- a/perf/tests/test_compare_responses.py +++ b/perf/tests/test_compare_responses.py @@ -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"502", b"502"), ("svod1-B", b"502", b"503"), + ("svod1-C", b"", 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()