"""Общий 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}" )