"""Pure, side-effect-free deep comparison.
Every function here takes plain values in and returns a `DiffResult` out.
No Behave dependency, no context, no I/O — fully unit-testable.
"""
from __future__ import annotations
import math
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
[docs]
@dataclass(frozen=True)
class Diff:
"""A single difference found while comparing ``actual`` to ``expected``."""
path: str
expected: object
actual: object
message: str
[docs]
@dataclass(frozen=True)
class DiffResult:
"""Outcome of a `deep_compare` call."""
equal: bool
diffs: list[Diff] = field(default_factory=list)
[docs]
@dataclass(frozen=True)
class CompareOptions:
"""Tunable behavior for `deep_compare`."""
ignore_keys: frozenset[str] = frozenset()
float_tolerance: float = 1e-9
ignore_order: bool = False
datetime_tolerance: timedelta | None = None
custom_matchers: dict[type, Callable[[Any, Any], bool]] = field(default_factory=dict)
[docs]
def deep_compare(
actual: object,
expected: object,
options: CompareOptions | None = None,
) -> DiffResult:
"""Recursively compare ``actual`` to ``expected`` and report all differences."""
options = options or CompareOptions()
diffs: list[Diff] = []
_compare(actual, expected, "", options, diffs, set())
return DiffResult(equal=not diffs, diffs=diffs)
def _join(path: str, key: object) -> str:
return f"{path}.{key}" if path else str(key)
def _mismatch(path: str, expected: object, actual: object) -> Diff:
return Diff(
path=path or "<root>",
expected=expected,
actual=actual,
message=f"expected {expected!r}, got {actual!r}",
)
def _custom_matcher(
actual: object,
expected: object,
custom_matchers: dict[type, Callable[[Any, Any], bool]],
) -> Callable[[Any, Any], bool] | None:
"""Return the most specific custom matcher that accepts both values."""
candidates = [
cls for cls in custom_matchers if isinstance(actual, cls) and isinstance(expected, cls)
]
if not candidates:
return None
for candidate in candidates:
if all(candidate is other or issubclass(candidate, other) for other in candidates):
return custom_matchers[candidate]
return custom_matchers[candidates[0]]
def _compare(
actual: object,
expected: object,
path: str,
options: CompareOptions,
diffs: list[Diff],
seen: set[tuple[int, int]],
) -> None:
if actual is expected:
return
pair = (id(actual), id(expected))
if pair in seen:
return
matcher = _custom_matcher(actual, expected, options.custom_matchers)
if matcher is not None:
if not matcher(actual, expected):
diffs.append(
Diff(
path=path or "<root>",
expected=expected,
actual=actual,
message=f"custom matcher failed for {type(expected).__name__}",
)
)
return
if isinstance(expected, bool) or isinstance(actual, bool):
if type(actual) is not type(expected) or actual != expected:
diffs.append(_mismatch(path, expected, actual))
return
if isinstance(expected, (int, float)) and isinstance(actual, (int, float)):
if math.isnan(actual) or math.isnan(expected):
diffs.append(_mismatch(path, expected, actual))
return
if abs(float(actual) - float(expected)) > options.float_tolerance:
diffs.append(_mismatch(path, expected, actual))
return
if isinstance(expected, datetime) and isinstance(actual, datetime):
tolerance = options.datetime_tolerance
if tolerance is None:
if actual != expected:
diffs.append(_mismatch(path, expected, actual))
else:
try:
delta = abs(actual - expected)
except TypeError:
diffs.append(_mismatch(path, expected, actual))
return
if delta > tolerance:
diffs.append(_mismatch(path, expected, actual))
return
if isinstance(actual, (list, tuple, dict, set, Mapping)) and isinstance(
expected, (list, tuple, dict, set, Mapping)
):
seen.add(pair)
if isinstance(expected, Mapping) and isinstance(actual, Mapping):
_compare_mapping(actual, expected, path, options, diffs, seen)
return
if isinstance(expected, (list, tuple)) and isinstance(actual, (list, tuple)):
_compare_sequence(actual, expected, path, options, diffs, seen)
return
if isinstance(expected, set) and isinstance(actual, set):
_compare_set(actual, expected, path, diffs)
return
if actual != expected:
diffs.append(_mismatch(path, expected, actual))
def _compare_mapping(
actual: Mapping[Any, Any],
expected: Mapping[Any, Any],
path: str,
options: CompareOptions,
diffs: list[Diff],
seen: set[tuple[int, int]],
) -> None:
actual_keys = set(actual.keys()) - options.ignore_keys
expected_keys = set(expected.keys()) - options.ignore_keys
for key in sorted(expected_keys - actual_keys, key=str):
diffs.append(
Diff(
path=_join(path, key),
expected=expected[key],
actual=None,
message=f"missing key '{key}'",
)
)
for key in sorted(actual_keys - expected_keys, key=str):
diffs.append(
Diff(
path=_join(path, key),
expected=None,
actual=actual[key],
message=f"unexpected key '{key}'",
)
)
for key in sorted(actual_keys & expected_keys, key=str):
_compare(actual[key], expected[key], _join(path, key), options, diffs, seen)
def _compare_sequence(
actual: Sequence[Any],
expected: Sequence[Any],
path: str,
options: CompareOptions,
diffs: list[Diff],
seen: set[tuple[int, int]],
) -> None:
if options.ignore_order:
_compare_sequence_unordered(actual, expected, path, options, diffs, seen)
return
if len(actual) != len(expected):
diffs.append(
Diff(
path=path or "<root>",
expected=list(expected),
actual=list(actual),
message=f"length mismatch: expected {len(expected)}, got {len(actual)}",
)
)
for index, expected_item in enumerate(expected):
if index >= len(actual):
break
_compare(actual[index], expected_item, f"{path}[{index}]", options, diffs, seen)
def _compare_sequence_unordered(
actual: Sequence[Any],
expected: Sequence[Any],
path: str,
options: CompareOptions,
diffs: list[Diff],
seen: set[tuple[int, int]],
) -> None:
remaining = list(actual)
for index, expected_item in enumerate(expected):
item_path = _join(path, index)
best_index: int | None = None
best_probe: list[Diff] | None = None
for candidate_index, actual_item in enumerate(remaining):
probe: list[Diff] = []
_compare(actual_item, expected_item, item_path, options, probe, seen)
if not probe:
best_index = candidate_index
best_probe = probe
break
if best_probe is None or len(probe) < len(best_probe):
best_index = candidate_index
best_probe = probe
if best_probe is not None and best_index is not None:
if best_probe:
diffs.extend(best_probe)
remaining.pop(best_index)
else:
diffs.append(
Diff(
path=item_path or "<root>",
expected=expected_item,
actual=None,
message=f"no matching item for expected[{index}]",
)
)
for extra_index, extra_item in enumerate(remaining):
extra_path = _join(path, f"?{extra_index}")
diffs.append(
Diff(
path=extra_path or "<root>",
expected=None,
actual=extra_item,
message=f"unexpected item {extra_item!r}",
)
)
def _compare_set(actual: set[Any], expected: set[Any], path: str, diffs: list[Diff]) -> None:
missing = expected - actual
extra = actual - expected
if missing or extra:
diffs.append(
Diff(
path=path or "<root>",
expected=expected,
actual=actual,
message=f"sets differ: missing={missing}, extra={extra}",
)
)