All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m34s
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Code-reviewer follow-up on the parity harness (aaaf8179): - parity.py: plain `==` in the scalar-equality branch let `True == 1` / `False == 0` silently pass (Python bool is an int subclass). A future migration bug turning a `bool | None` field into a raw 0/1 would slip through undetected. Now any type mismatch where exactly one side is a bool is reported as a diff, regardless of numeric equality. - test_parity.py: unit test for the new bool-vs-int branch. - test_avito_detail_kit_parity.py: the existing smoke test only proved the harness reports "no diff" on two genuinely-identical real dataclass instances — it never proved the harness catches a real divergence on this same 30+-field shape (only the toy fixtures in test_parity.py did). Added a test that mutates `price_rub` via dataclasses.replace() on the real kit DetailEnrichment and asserts assert_parity raises ParityMismatchError naming that field.
187 lines
8.7 KiB
Python
187 lines
8.7 KiB
Python
"""Общий harness для parity-тестов legacy → scraper_kit (issue #2304).
|
||
|
||
Каждая миграция importer'а legacy `app/services/scrapers/*` на его
|
||
`scraper_kit` эквивалент (issues #2305-#2310) должна доказать, что kit-путь
|
||
даёт ТОТ ЖЕ результат, что и legacy-путь на одном и том же входе. Это —
|
||
переиспользуемый инструмент для такого доказательства, НЕ сама миграция.
|
||
|
||
Usage (см. tests/support/README.md для подробностей)::
|
||
|
||
from tests.support.parity import assert_parity
|
||
|
||
def test_parse_detail_html_parity() -> None:
|
||
assert_parity(
|
||
legacy_fn=legacy_parse_detail_html,
|
||
kit_fn=kit_parse_detail_html,
|
||
fixtures=[(html, source_url)],
|
||
ignore_fields={"latency_ms", "fetched_at"},
|
||
tolerance=1e-6,
|
||
)
|
||
|
||
Design note: legacy- и kit-версии одного и того же dataclass (напр.
|
||
``DetailEnrichment``) — это РАЗНЫЕ классы (разные модули), поэтому обычный
|
||
``==`` между их инстансами всегда False (dataclass ``__eq__`` сравнивает
|
||
``other.__class__ is self.__class__`` первым делом). Поэтому сравнение
|
||
нормализует оба вывода в dict/list/scalar (``_normalize``) и сравнивает
|
||
СТРУКТУРНО, по полям — а не через identity классов.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import dataclasses
|
||
import math
|
||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||
from typing import Any
|
||
|
||
|
||
class ParityMismatchError(AssertionError):
|
||
"""legacy_fn(...) и kit_fn(...) дали разный результат на одной фикстуре.
|
||
|
||
Подкласс AssertionError — совместим с `pytest.raises(AssertionError)`
|
||
и обычным assert-репортингом pytest.
|
||
"""
|
||
|
||
|
||
def _normalize(value: Any) -> Any:
|
||
"""Приводит dataclass / pydantic-модель к сравнимому dict/list/scalar.
|
||
|
||
Рекурсивно — вложенные dataclass/pydantic-поля тоже нормализуются.
|
||
"""
|
||
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
||
return {f.name: _normalize(getattr(value, f.name)) for f in dataclasses.fields(value)}
|
||
if hasattr(value, "model_dump") and callable(value.model_dump):
|
||
return _normalize(value.model_dump())
|
||
if isinstance(value, Mapping):
|
||
return {k: _normalize(v) for k, v in value.items()}
|
||
if isinstance(value, list | tuple):
|
||
return [_normalize(v) for v in value]
|
||
return value
|
||
|
||
|
||
def _is_number(value: Any) -> bool:
|
||
# bool — подкласс int, но семантически не "число с допуском".
|
||
return isinstance(value, int | float) and not isinstance(value, bool)
|
||
|
||
|
||
def _within_tolerance(legacy_val: Any, kit_val: Any, tolerance: float) -> bool:
|
||
if not tolerance or not _is_number(legacy_val) or not _is_number(kit_val):
|
||
return False
|
||
return math.isclose(float(legacy_val), float(kit_val), rel_tol=tolerance, abs_tol=tolerance)
|
||
|
||
|
||
def _diff(
|
||
path: str,
|
||
legacy_val: Any,
|
||
kit_val: Any,
|
||
ignore_fields: frozenset[str],
|
||
tolerance: float,
|
||
diffs: list[str],
|
||
) -> None:
|
||
legacy_val = _normalize(legacy_val)
|
||
kit_val = _normalize(kit_val)
|
||
|
||
legacy_is_map = isinstance(legacy_val, Mapping)
|
||
kit_is_map = isinstance(kit_val, Mapping)
|
||
if legacy_is_map or kit_is_map:
|
||
if not (legacy_is_map and kit_is_map):
|
||
diffs.append(f"{path}: type mismatch — legacy={legacy_val!r}, kit={kit_val!r}")
|
||
return
|
||
for key in sorted({*legacy_val.keys(), *kit_val.keys()}, key=str):
|
||
if key in ignore_fields:
|
||
continue
|
||
child_path = f"{path}.{key}"
|
||
if key not in legacy_val:
|
||
diffs.append(f"{child_path}: missing in legacy, kit={kit_val[key]!r}")
|
||
elif key not in kit_val:
|
||
diffs.append(f"{child_path}: legacy={legacy_val[key]!r}, missing in kit")
|
||
else:
|
||
_diff(child_path, legacy_val[key], kit_val[key], ignore_fields, tolerance, diffs)
|
||
return
|
||
|
||
legacy_is_seq = isinstance(legacy_val, list | tuple)
|
||
kit_is_seq = isinstance(kit_val, list | tuple)
|
||
if legacy_is_seq or kit_is_seq:
|
||
if not (legacy_is_seq and kit_is_seq):
|
||
diffs.append(f"{path}: type mismatch — legacy={legacy_val!r}, kit={kit_val!r}")
|
||
return
|
||
if len(legacy_val) != len(kit_val):
|
||
diffs.append(f"{path}: length differs — legacy={len(legacy_val)}, kit={len(kit_val)}")
|
||
return
|
||
for i, (lv, kv) in enumerate(zip(legacy_val, kit_val, strict=True)):
|
||
_diff(f"{path}[{i}]", lv, kv, ignore_fields, tolerance, diffs)
|
||
return
|
||
|
||
if isinstance(legacy_val, bool) != isinstance(kit_val, bool):
|
||
# bool — подкласс int в Python, поэтому `True == 1` и `False == 0` дают True
|
||
# при обычном `==` (проверять надо isinstance(x, bool), НЕ isinstance(x, int)).
|
||
# Миграция, случайно превратившая `bool | None` поле в сырой 0/1 (или наоборот),
|
||
# должна считаться расхождением, даже если числовое значение совпадает.
|
||
diffs.append(
|
||
f"{path}: type mismatch (bool vs non-bool) — legacy={legacy_val!r}, kit={kit_val!r}"
|
||
)
|
||
return
|
||
if legacy_val == kit_val:
|
||
return
|
||
if _within_tolerance(legacy_val, kit_val, tolerance):
|
||
return
|
||
diffs.append(f"{path}: legacy={legacy_val!r} != kit={kit_val!r}")
|
||
|
||
|
||
def compare_outputs(
|
||
legacy_output: Any,
|
||
kit_output: Any,
|
||
*,
|
||
ignore_fields: Iterable[str] = (),
|
||
tolerance: float = 0.0,
|
||
) -> list[str]:
|
||
"""Возвращает список текстовых различий между двумя выводами (пусто = совпадают).
|
||
|
||
ignore_fields — имена полей (ключи dict / поля dataclass на ЛЮБОМ уровне
|
||
вложенности), которые пропускаются при сравнении — для недетерминированных
|
||
полей вроде ``latency_ms`` / ``fetched_at`` / ``request_id``.
|
||
tolerance — абсолютно-и-относительный допуск (``math.isclose``) для
|
||
числовых полей (int/float, НЕ bool). Не применяется к datetime/date —
|
||
такие поля, если недетерминированы, кладите в ignore_fields.
|
||
"""
|
||
diffs: list[str] = []
|
||
_diff("$", legacy_output, kit_output, frozenset(ignore_fields), tolerance, diffs)
|
||
return diffs
|
||
|
||
|
||
def assert_parity(
|
||
legacy_fn: Callable[..., Any],
|
||
kit_fn: Callable[..., Any],
|
||
fixtures: Sequence[Any],
|
||
*,
|
||
ignore_fields: Iterable[str] = (),
|
||
tolerance: float = 0.0,
|
||
) -> None:
|
||
"""Прогоняет legacy_fn и kit_fn на каждой фикстуре, сверяет вывод.
|
||
|
||
Args:
|
||
legacy_fn: функция из `app/services/scrapers/*` (legacy-путь).
|
||
kit_fn: соответствующая функция из `scraper_kit` (kit-путь).
|
||
fixtures: список входов. Каждый элемент фикстуры:
|
||
- tuple/list → распаковывается как позиционные аргументы (``*args``);
|
||
- любое другое значение (dict, str, ...) → передаётся как ЕДИНСТВЕННЫЙ
|
||
позиционный аргумент.
|
||
ignore_fields: имена полей, игнорируемых при сравнении (см. compare_outputs).
|
||
tolerance: числовой допуск для float-полей (см. compare_outputs).
|
||
|
||
Raises:
|
||
ParityMismatchError: если legacy_fn(...) != kit_fn(...) хотя бы на одной
|
||
фикстуре. Сообщение перечисляет ВСЕ различающиеся поля (path + old/new).
|
||
"""
|
||
for i, fixture in enumerate(fixtures):
|
||
args = fixture if isinstance(fixture, tuple | list) else (fixture,)
|
||
legacy_result = legacy_fn(*args)
|
||
kit_result = kit_fn(*args)
|
||
|
||
diffs = compare_outputs(
|
||
legacy_result, kit_result, ignore_fields=ignore_fields, tolerance=tolerance
|
||
)
|
||
if diffs:
|
||
diff_text = "\n".join(f" - {d}" for d in diffs)
|
||
raise ParityMismatchError(
|
||
f"Parity mismatch on fixture #{i} (input={fixture!r}):\n{diff_text}"
|
||
)
|