Stage 0 of the scraper_kit migration epic (#2277): shared test tool for issues #2305-#2310, which each need to prove their kit-path importer produces the same output as the legacy path on the same input. - tests/support/parity.py: assert_parity()/compare_outputs() normalize dataclass/pydantic outputs to dict/list/scalar before comparing, since legacy vs kit dataclasses (e.g. DetailEnrichment) are different classes and dataclass __eq__ always returns False across classes even when all field values match. Supports ignore_fields (drop non-deterministic fields like latency_ms/fetched_at) and numeric tolerance (math.isclose) for float fields, with an assertion listing every differing field (path + legacy value + kit value) on mismatch. - tests/support/test_parity.py: unit tests for the harness itself (identical outputs pass, differing outputs raise with informative diff, tolerance/ignore_fields options, cross-class dataclass parity). - tests/scrapers/test_avito_detail_kit_parity.py: end-to-end smoke proof against real code — app.services.scrapers.avito_detail.parse_detail_html (legacy, reached via admin.py's scrape_avito_detail debug endpoint through fetch_detail) vs scraper_kit.providers.avito.detail's copy, on a fixed HTML fixture. - tests/support/README.md: usage note for #2305-#2310 migration PRs. Found while implementing: tests/test_scraper_kit_*_parity.py (9 files, ~3400 lines) already do ad-hoc `dataclasses.asdict(old) == asdict(new)` parity checks for the already-migrated SERP scraper modules (avito/cian/ domclick/yandex/base/scheduler/pipeline) — this harness generalizes that repeated pattern for the remaining 12 non-scraper importers, adding ignore_fields/tolerance which those ad-hoc checks don't have.
178 lines
8 KiB
Python
178 lines
8 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 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}"
|
||
)
|