"""Argument matcher objects for use inside expected-args dicts.
Matchers are placed as *values* inside the ``expected`` argument dicts passed
to :func:`toolscore.evaluate`. They work via operator overloading:
``Matcher.__eq__(other)`` performs the match, so they compose transparently
with plain dict equality and with the argument-comparison logic in
:mod:`toolscore.metrics.arguments`.
Example::
from toolscore import evaluate, ANY, Regex, Approx
result = evaluate(
expected=[{"tool": "get_weather", "args": {"city": Regex(r"NYC|JFK")}}],
actual=[{"tool": "get_weather", "args": {"city": "NYC"}}],
)
assert result.argument_f1 == 1.0
"""
from __future__ import annotations
import math
import re
from abc import ABC, abstractmethod
[docs]
class Matcher(ABC):
"""Abstract base class for all argument matchers.
Subclasses implement :meth:`matches` which is called by ``__eq__``.
Because ``__eq__`` is overridden, ``__hash__`` must be explicitly
preserved — we delegate to :meth:`object.__hash__` so matchers
remain usable as dict keys / set members.
"""
[docs]
@abstractmethod
def matches(self, value: object) -> bool:
"""Return True if *value* satisfies this matcher."""
def __eq__(self, other: object) -> bool:
return self.matches(other)
# Explicitly preserve hash: overriding __eq__ would otherwise set it to
# None (which breaks sets/dicts and makes mypy strict complain).
__hash__ = object.__hash__
@abstractmethod
def __repr__(self) -> str: ...
# ---------------------------------------------------------------------------
# ANY singleton
# ---------------------------------------------------------------------------
class _AnyMatcher(Matcher):
"""Singleton matcher that matches any value."""
_instance: _AnyMatcher | None = None
def __new__(cls) -> _AnyMatcher:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def matches(self, value: object) -> bool: # noqa: ARG002
return True
def __repr__(self) -> str:
return "ANY"
ANY: _AnyMatcher = _AnyMatcher()
"""Module-level singleton; matches any value."""
# ---------------------------------------------------------------------------
# Regex
# ---------------------------------------------------------------------------
[docs]
class Regex(Matcher):
"""Full-match a string against a regular expression pattern.
Non-string values never match.
Args:
pattern: Regular expression pattern string.
flags: Optional :mod:`re` flags (e.g. ``re.IGNORECASE``).
"""
[docs]
def __init__(self, pattern: str, flags: int = 0) -> None:
self._pattern = pattern
self._flags = flags
self._compiled = re.compile(pattern, flags)
[docs]
def matches(self, value: object) -> bool:
if not isinstance(value, str):
return False
return self._compiled.fullmatch(value) is not None
def __repr__(self) -> str:
if self._flags:
return f"Regex({self._pattern!r}, flags={self._flags})"
return f"Regex({self._pattern!r})"
# ---------------------------------------------------------------------------
# Approx
# ---------------------------------------------------------------------------
[docs]
class Approx(Matcher):
"""Numeric closeness matcher (similar to ``pytest.approx`` semantics).
Matches ``int`` and ``float`` values but explicitly excludes ``bool``
(even though ``bool`` is a subclass of ``int`` in Python).
The comparison uses the *larger* of the relative and absolute tolerances::
|actual - expected| <= max(rel * |expected|, abs_tol)
Args:
value: The expected numeric value.
rel: Relative tolerance (default 1e-6).
abs: Absolute tolerance (default 0.0). Note this differs from
``pytest.approx``, whose default absolute tolerance is ``1e-12``.
With ``abs=0.0`` the match is purely relative, so a comparison
against an expected value of ``0`` requires exact equality.
"""
[docs]
def __init__(self, value: float, rel: float = 1e-6, abs: float = 0.0) -> None:
self._value = value
self._rel = rel
self._abs = abs
[docs]
def matches(self, value: object) -> bool:
# Explicitly reject booleans (bool is a subclass of int)
if isinstance(value, bool):
return False
if not isinstance(value, (int, float)):
return False
tolerance = max(self._rel * math.fabs(self._value), self._abs)
return math.fabs(float(value) - float(self._value)) <= tolerance
def __repr__(self) -> str:
parts = [repr(self._value)]
if self._rel != 1e-6:
parts.append(f"rel={self._rel!r}")
if self._abs != 0.0:
parts.append(f"abs={self._abs!r}")
return f"Approx({', '.join(parts)})"
# ---------------------------------------------------------------------------
# Contains
# ---------------------------------------------------------------------------
[docs]
class Contains(Matcher):
"""Membership matcher: checks ``item in value``.
Works for ``str``, ``list``, ``tuple``, ``set``, and ``dict`` (key
membership for dicts). Non-container types never match.
Args:
item: The item to look for inside the value.
"""
[docs]
def __init__(self, item: object) -> None:
self._item = item
[docs]
def matches(self, value: object) -> bool:
if not isinstance(value, (str, list, tuple, set, dict, frozenset)):
return False
try:
return self._item in value
except TypeError:
return False
def __repr__(self) -> str:
return f"Contains({self._item!r})"
# ---------------------------------------------------------------------------
# OneOf
# ---------------------------------------------------------------------------
[docs]
class OneOf(Matcher):
"""Value-is-one-of matcher.
Checks whether *value* equals any of the provided *values*. The
comparison uses ``==`` so the provided values may themselves be
:class:`Matcher` instances (their ``__eq__`` will be invoked).
Args:
*values: Candidate values (or Matchers) to test against.
"""
[docs]
def __init__(self, *values: object) -> None:
self._values: tuple[object, ...] = values
[docs]
def matches(self, value: object) -> bool:
# candidate == value triggers Matcher.__eq__ if candidate is a Matcher
return any(candidate == value for candidate in self._values)
def __repr__(self) -> str:
inner = ", ".join(repr(v) for v in self._values)
return f"OneOf({inner})"
# ---------------------------------------------------------------------------
# IsType
# ---------------------------------------------------------------------------
[docs]
class IsType(Matcher):
"""Type-check matcher using :func:`isinstance`.
.. note::
``IsType(int)`` does **not** match ``True`` or ``False`` even though
``bool`` is a subclass of ``int`` in Python. This avoids a common
footgun when you want to match plain integers but not accidental
booleans. Use ``IsType(bool)`` explicitly to match booleans.
Args:
*types: One or more types to check against.
"""
[docs]
def __init__(self, *types: type) -> None:
self._types: tuple[type, ...] = types
[docs]
def matches(self, value: object) -> bool:
# Special-case: if bool is not in the requested types, reject booleans
# even though isinstance(True, int) is True.
if isinstance(value, bool) and bool not in self._types:
return False
return isinstance(value, self._types)
def __repr__(self) -> str:
names = ", ".join(t.__name__ for t in self._types)
return f"IsType({names})"
# ---------------------------------------------------------------------------
# Public re-exports
# ---------------------------------------------------------------------------
__all__ = [
"ANY",
"Approx",
"Contains",
"IsType",
"Matcher",
"OneOf",
"Regex",
]