From a9b52377b333c5690f4937922d400d84702706d3 Mon Sep 17 00:00:00 2001 From: reachsridhard Date: Sun, 13 Sep 2026 15:41:07 +0530 Subject: [PATCH] fix: use backtracking for list_any_order matching (#1102) --- tavern/_core/dict_util.py | 82 +++++++++++++++++++++++++----------- tests/unit/test_utilities.py | 12 ++++++ 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/tavern/_core/dict_util.py b/tavern/_core/dict_util.py index 8dc76499..4696d15e 100644 --- a/tavern/_core/dict_util.py +++ b/tavern/_core/dict_util.py @@ -486,34 +486,66 @@ def _format_err(which): if strict_setting == StrictSetting.LIST_ANY_ORDER: # Each response item can only be used to satisfy one expected - # item - remove it from the pool of remaining candidates once - # matched so duplicate expected values aren't matched against - # the same response item more than once. - remaining = list(actual_val) - - for i, e_val in enumerate(expected_val): - for idx, current_response_val in enumerate(remaining): + # item. Build a compatibility matrix once so each + # expected/actual pair is only compared once, then use + # bipartite maximum matching to find the largest set of + # expected items satisfied by unique actual items. This + # keeps matching polynomial even when broad matchers (e.g. + # !anything) can consume many different response items. + n_expected = len(expected_val) + n_actual = len(actual_val) + + def _is_compatible(e_val, a_val, e_idx) -> bool: + """Check if a single expected item matches a single + response item without consuming it.""" + logger.debug( + "Got '%s' from response to check against '%s' from expected", + a_val, + e_val, + ) + try: + check_keys_match_recursive( + e_val, a_val, keys + [e_idx], strict + ) + except exceptions.KeyMismatchError: logger.debug( - "Got '%s' from response to check against '%s' from expected", - current_response_val, + "%s did not match response value %s", e_val, + a_val, ) - - try: - check_keys_match_recursive( - e_val, current_response_val, keys + [i], strict - ) - except exceptions.KeyMismatchError: - # Doesn't match what we're looking for - logger.debug( - "%s did not match response value %s", - e_val, - current_response_val, - ) - else: - logger.debug("'%s' present in response", e_val) - del remaining[idx] - break + return False + return True + + compatible = [ + [_is_compatible(e_val, a_val, i) for a_val in actual_val] + for i, e_val in enumerate(expected_val) + ] + + # match_to_actual[j] = index of the expected item assigned + # to actual item j (-1 = unassigned) + match_to_actual = [-1] * n_actual + + def _augment(e_idx: int, seen: set[int]) -> bool: + """Find an augmenting path from expected item e_idx to a + free actual item, rematching along the way.""" + for j in range(n_actual): + if not compatible[e_idx][j] or j in seen: + continue + seen.add(j) + if match_to_actual[j] == -1 or _augment( + match_to_actual[j], seen + ): + match_to_actual[j] = e_idx + return True + return False + + for i in range(n_expected): + _augment(i, set()) + + matched_expected = {e for e in match_to_actual if e != -1} + for i, e_val in enumerate(expected_val): + if i in matched_expected: + logger.debug("'%s' present in response", e_val) else: logger.debug("Ran out of list response items to check") missing.append(e_val) diff --git a/tests/unit/test_utilities.py b/tests/unit/test_utilities.py index 7eee557c..237add57 100644 --- a/tests/unit/test_utilities.py +++ b/tests/unit/test_utilities.py @@ -297,6 +297,7 @@ class TestListAnyOrderMatching: item should still only be able to satisfy one expected item""" def strict(self): + """Strictness setting for list_any_order tests.""" return StrictOption("json", StrictSetting.LIST_ANY_ORDER) def test_match_any_order(self): @@ -329,6 +330,17 @@ def test_does_not_match_missing_item(self): with pytest.raises(exceptions.KeyMismatchError): check_keys_match_recursive(a, b, [], strict=self.strict()) + def test_broad_matcher_does_not_consume_specific_item(self): + """A broad matcher (e.g. !anything) should not greedily consume an + actual item that a later, more specific expected item needs. + + Regression test for https://github.com/taverntesting/tavern/issues/1102 + """ + a = [ANYTHING, {"id": 1}] + b = [{"id": 1}, {"id": 2}] + + check_keys_match_recursive(a, b, [], strict=self.strict()) + @pytest.fixture(name="test_yaml") def fix_test_yaml():