|
1 | 1 | import warnings |
2 | 2 | from abc import ABC, abstractmethod |
3 | | -from typing import Any, Self, override |
| 3 | +from typing import Any, Literal, Self, override |
4 | 4 |
|
5 | 5 | from pydantic import Field, model_validator |
6 | 6 | from pydantic.experimental.missing_sentinel import MISSING |
|
11 | 11 | from ..core.result import CheckResult |
12 | 12 | from ..utils.normalization import NormalizationForm, normalize_data |
13 | 13 |
|
| 14 | +MatchMode = Literal["any", "all", "none"] |
| 15 | +type MatchCollection[T] = list[T] | set[T] | tuple[T, ...] |
| 16 | + |
14 | 17 |
|
15 | 18 | class ComparisonCheck[InputType, OutputType, TraceType: Trace, ExpectedType]( # pyright: ignore[reportMissingTypeArgument] |
16 | 19 | ABC, Check[InputType, OutputType, TraceType] |
@@ -48,6 +51,15 @@ class ComparisonCheck[InputType, OutputType, TraceType: Trace, ExpectedType]( # |
48 | 51 | default="NFKC", |
49 | 52 | description="Unicode normalization form to apply before comparison. Defaults to NFKC.", |
50 | 53 | ) |
| 54 | + match: MatchMode | MISSING = Field( |
| 55 | + default=MISSING, |
| 56 | + description=( |
| 57 | + "How to apply the comparison when the resolved actual value is a collection. " |
| 58 | + "When omitted, the resolved value is compared directly. " |
| 59 | + "'any' passes if at least one item matches, 'all' if every item matches, " |
| 60 | + "'none' if no item matches. Requires a list, set, or tuple." |
| 61 | + ), |
| 62 | + ) |
51 | 63 |
|
52 | 64 | @abstractmethod |
53 | 65 | def _compare(self, actual_value: Any, expected_value: ExpectedType) -> bool: |
@@ -75,6 +87,125 @@ def validate_expected_value_or_expected_value_key(self) -> Self: |
75 | 87 | ) |
76 | 88 | return self |
77 | 89 |
|
| 90 | + def _compare_normalized( |
| 91 | + self, normalized_actual: Any, normalized_expected: ExpectedType |
| 92 | + ) -> bool | None: |
| 93 | + try: |
| 94 | + return self._compare(normalized_actual, normalized_expected) |
| 95 | + except Exception: |
| 96 | + return None |
| 97 | + |
| 98 | + def _try_compare( |
| 99 | + self, actual_value: Any, expected_value: ExpectedType |
| 100 | + ) -> bool | None: |
| 101 | + return self._compare_normalized( |
| 102 | + normalize_data(actual_value, self.normalization_form), |
| 103 | + normalize_data(expected_value, self.normalization_form), |
| 104 | + ) |
| 105 | + |
| 106 | + def _try_compare_to_normalized_expected( |
| 107 | + self, actual_value: Any, normalized_expected: ExpectedType |
| 108 | + ) -> bool | None: |
| 109 | + return self._compare_normalized( |
| 110 | + normalize_data(actual_value, self.normalization_form), |
| 111 | + normalized_expected, |
| 112 | + ) |
| 113 | + |
| 114 | + def _unsupported_comparison_message( |
| 115 | + self, actual_value: Any, expected_value: ExpectedType |
| 116 | + ) -> str: |
| 117 | + return ( |
| 118 | + f"Comparison not supported: items in {type(actual_value).__name__} " |
| 119 | + f"do not support {self._operator_symbol} comparison with " |
| 120 | + f"{type(expected_value).__name__}" |
| 121 | + ) |
| 122 | + |
| 123 | + def _collection_match_message( |
| 124 | + self, |
| 125 | + passed: bool, |
| 126 | + actual_value: Any, |
| 127 | + expected_value: ExpectedType, |
| 128 | + matched_items: list[Any] | None = None, |
| 129 | + ) -> str: |
| 130 | + actual_repr = repr(actual_value) |
| 131 | + expected_repr = repr(expected_value) |
| 132 | + comparison = self._comparison_message |
| 133 | + if self.match == "any": |
| 134 | + if passed: |
| 135 | + return f"At least one value in {actual_repr} is {comparison} {expected_repr}." |
| 136 | + return ( |
| 137 | + f"Expected at least one value {comparison} {expected_repr} " |
| 138 | + f"but none matched in {actual_repr}." |
| 139 | + ) |
| 140 | + if self.match == "all": |
| 141 | + if passed: |
| 142 | + return f"All values in {actual_repr} are {comparison} {expected_repr}." |
| 143 | + return f"Expected all values {comparison} {expected_repr} but got {actual_repr}." |
| 144 | + if passed: |
| 145 | + return f"No value in {actual_repr} is {comparison} {expected_repr}." |
| 146 | + return ( |
| 147 | + f"Expected no value {comparison} {expected_repr} " |
| 148 | + f"but found matches in {repr(matched_items)}." |
| 149 | + ) |
| 150 | + |
| 151 | + def _run_collection_match( |
| 152 | + self, |
| 153 | + actual_value: Any, |
| 154 | + expected_value: ExpectedType, |
| 155 | + details: dict[str, Any], |
| 156 | + ) -> CheckResult: |
| 157 | + if not isinstance(actual_value, (list, set, tuple)): |
| 158 | + return CheckResult.failure( |
| 159 | + message=( |
| 160 | + f"Expected a list, set, or tuple at key '{self.key}' when match is " |
| 161 | + f"{self.match!r}, but got {type(actual_value).__name__}." |
| 162 | + ), |
| 163 | + details=details, |
| 164 | + ) |
| 165 | + |
| 166 | + collection: MatchCollection[Any] = actual_value |
| 167 | + normalized_expected = normalize_data(expected_value, self.normalization_form) |
| 168 | + comparison_results: list[bool | None] = [] |
| 169 | + matched_items: list[Any] = [] |
| 170 | + for item in collection: |
| 171 | + result = self._try_compare_to_normalized_expected(item, normalized_expected) |
| 172 | + comparison_results.append(result) |
| 173 | + if result: |
| 174 | + matched_items.append(item) |
| 175 | + |
| 176 | + if any(result is None for result in comparison_results): |
| 177 | + return CheckResult.failure( |
| 178 | + message=self._unsupported_comparison_message( |
| 179 | + actual_value, expected_value |
| 180 | + ), |
| 181 | + details=details, |
| 182 | + ) |
| 183 | + |
| 184 | + if self.match == "any": |
| 185 | + passed = any(comparison_results) |
| 186 | + elif self.match == "all": |
| 187 | + passed = all(comparison_results) |
| 188 | + else: |
| 189 | + passed = not any(comparison_results) |
| 190 | + |
| 191 | + if passed: |
| 192 | + return CheckResult.success( |
| 193 | + message=self._collection_match_message( |
| 194 | + passed, actual_value, expected_value |
| 195 | + ), |
| 196 | + details=details, |
| 197 | + ) |
| 198 | + |
| 199 | + return CheckResult.failure( |
| 200 | + message=self._collection_match_message( |
| 201 | + passed, |
| 202 | + actual_value, |
| 203 | + expected_value, |
| 204 | + matched_items if self.match == "none" else None, |
| 205 | + ), |
| 206 | + details=details, |
| 207 | + ) |
| 208 | + |
78 | 209 | @override |
79 | 210 | async def run(self, trace: TraceType) -> CheckResult: |
80 | 211 | """Execute the check against the provided trace.""" |
@@ -102,21 +233,20 @@ async def run(self, trace: TraceType) -> CheckResult: |
102 | 233 | details=details, |
103 | 234 | ) |
104 | 235 |
|
105 | | - normalized_actual_value = normalize_data(actual_value, self.normalization_form) |
106 | | - normalized_expected_value = normalize_data( |
107 | | - expected_value, self.normalization_form |
108 | | - ) |
109 | | - try: |
110 | | - if self._compare(normalized_actual_value, normalized_expected_value): |
111 | | - return CheckResult.success( |
112 | | - message=f"The actual value {repr(actual_value)} is {self._comparison_message} the expected value {repr(expected_value)}.", |
113 | | - details=details, |
114 | | - ) |
115 | | - except Exception: |
| 236 | + if self.match is not MISSING: |
| 237 | + return self._run_collection_match(actual_value, expected_value, details) |
| 238 | + |
| 239 | + compare_result = self._try_compare(actual_value, expected_value) |
| 240 | + if compare_result is None: |
116 | 241 | return CheckResult.failure( |
117 | 242 | message=f"Comparison not supported: {type(actual_value).__name__} does not support {self._operator_symbol} comparison with {type(expected_value).__name__}", |
118 | 243 | details=details, |
119 | 244 | ) |
| 245 | + if compare_result: |
| 246 | + return CheckResult.success( |
| 247 | + message=f"The actual value {repr(actual_value)} is {self._comparison_message} the expected value {repr(expected_value)}.", |
| 248 | + details=details, |
| 249 | + ) |
120 | 250 |
|
121 | 251 | return CheckResult.failure( |
122 | 252 | message=f"Expected value {self._comparison_message} {repr(expected_value)} but got {repr(actual_value)}", |
|
0 commit comments