Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 57 additions & 25 deletions tavern/_core/dict_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/test_utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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():
Expand Down
Loading