import collections
import inspect
from collections.abc import Iterable, Mapping
from itertools import zip_longest
from typing import Any, Optional, Union
from hamcrest import (
all_of,
greater_than,
greater_than_or_equal_to,
less_than,
less_than_or_equal_to,
not_,
)
from hamcrest.core.base_matcher import BaseMatcher
from hamcrest.core.description import Description
from hamcrest.core.helpers.wrap_matcher import wrap_matcher
from hamcrest.core.matcher import Matcher
[docs]
def has_repr(expected: Any) -> Matcher[Any]:
"""Matches if the object's ``repr()`` matches the expected string or matcher.
:param expected: The expected string representation or a string matcher.
:return: A matcher that validates ``repr(obj)``.
"""
return HasRepr(expected)
[docs]
class HasRepr(BaseMatcher[Any]):
def __init__(self, expected: Union[str, Matcher[str]]) -> None:
self.expected: Matcher[str] = wrap_matcher(expected)
def _matches(self, actual: Any) -> bool:
return self.expected.matches(repr(actual))
[docs]
def describe_to(self, description: Description) -> None:
description.append_text("an object with repr() matching ")
self.expected.describe_to(description)
[docs]
def has_identical_properties_to(expected: Any, ignoring: Optional[Iterable[str]] = None) -> Matcher[Any]:
"""Matches an object if its public properties and attributes are identical to the expected object's.
This matcher performs a deep recursive comparison of all public attributes (those not starting with ``_``)
and properties. It gracefully handles nested dictionaries and sequences.
:param expected: The reference object to compare against.
:param ignoring: A collection of attribute names to exclude from the comparison.
:return: A matcher for object equality based on public state.
"""
return HasIdenticalPropertiesTo(expected, ignoring=ignoring)
[docs]
class HasIdenticalPropertiesTo(BaseMatcher[Any]):
def __init__(self, expected: Any, ignoring: Optional[Iterable[str]] = None) -> None:
self.expected = expected
self.ignoring = ignoring
def _matches(self, actual: Any) -> bool:
return equal_vars(actual, self.expected, ignoring=self.ignoring)
# TODO: Needs a describe_mismatch()
[docs]
def describe_to(self, description: Description) -> None:
description.append_text("object with identical properties to object ").append_description_of(self.expected)
if self.ignoring:
description.append_text(" ignoring properties named ").append_list("{", ", ", "}", self.ignoring)
[docs]
def equal_vars(left: Any, right: Any, ignoring: Optional[Iterable[str]] = None) -> bool:
"""Test if two objects are equal using public vars() and properties if available, with == otherwise.
:param left: The first object to compare.
:param right: The second object to compare.
:param ignoring: Optional list of attribute names to ignore.
:return: True if objects are equivalent, False otherwise.
"""
try:
left_vars = _vars_and_properties(left, ignoring=ignoring)
right_vars = _vars_and_properties(right, ignoring=ignoring)
except TypeError:
return _equal_vars_for_non_objects(left, right)
else:
return left_vars.keys() == right_vars.keys() and all(
equal_vars(right_vars[key], value, ignoring=ignoring) for key, value in left_vars.items()
)
def _equal_vars_for_non_objects(left: Any, right: Any) -> bool:
if (
isinstance(left, collections.abc.Sequence)
and not isinstance(left, str)
and isinstance(right, collections.abc.Sequence)
and not isinstance(right, str)
):
return all(equal_vars(left_var, right_var) for left_var, right_var in zip_longest(left, right))
if isinstance(left, collections.abc.Mapping) and isinstance(right, collections.abc.Mapping):
return left.keys() == right.keys() and all(equal_vars(right[key], value) for key, value in left.items())
return left == right
def _vars_and_properties(obj: Any, ignoring: Optional[Iterable[str]] = None) -> Mapping[str, Any]:
"""Get an object's public vars() and properties. Raises TypeError if not an object with vars()."""
ignoring = ignoring or {}
vars_and_props = {
key: value for key, value in vars(obj).items() if not key.startswith("_") and key not in ignoring
} # vars
classes = inspect.getmembers(obj, inspect.isclass)
for cls in classes: # props
props = inspect.getmembers(cls[1], lambda o: isinstance(o, property))
for prop in props:
name = prop[0]
if name not in ignoring:
vars_and_props[name] = getattr(obj, name)
return vars_and_props
[docs]
class Truthy(BaseMatcher[Any]):
[docs]
def describe_to(self, description: Description) -> None:
description.append_text("Truthy value")
def _matches(self, item: Any) -> bool:
return bool(item)
[docs]
def true() -> Matcher[Any]:
"""Matches any value that evaluates to True in a boolean context (truthy).
:return: A matcher for truthiness.
"""
return Truthy()
[docs]
def false() -> Matcher[Any]:
"""Matches any value that evaluates to False in a boolean context (falsy).
:return: A matcher for falsiness.
"""
return not_(true())
[docs]
def between(lower: Any, upper: Any, *, lower_inclusive=True, upper_inclusive=True) -> Matcher[Any]:
"""Matches if a value is within a specific range.
:param lower: The lower bound of the range.
:param upper: The upper bound of the range.
:param lower_inclusive: If True, the range includes the lower bound (>=).
If False, it is exclusive (>). Defaults to True.
:param upper_inclusive: If True, the range includes the upper bound (<=).
If False, it is exclusive (<). Defaults to True.
:return: A matcher for the specified range.
"""
return all_of(
greater_than_or_equal_to(lower) if lower_inclusive else greater_than(lower),
less_than_or_equal_to(upper) if upper_inclusive else less_than(upper),
)