fix: count exclude_regex_paths towards threshold_to_diff_deeper

The shortcut in _diff_dict that reports a whole dictionary as a single
values_changed entry compensates its key count for exclude_paths and
exclude_glob_paths, but never for exclude_regex_paths.

Regex-excluded keys are not removed from the key sets beforehand either,
since _skip_this_key only filters on include_paths and include_glob_paths.
They therefore inflate the key union, drag the ratio of shared keys under
the threshold and trigger the shortcut, so the exclusion is silently
ignored and the excluded keys leak into old_value and new_value.

Filter the key union by exclude_regex_paths as well, mirroring the
exclude_glob_paths branch and using search() to match how _skip_this
applies the same patterns.

Fixes #487
This commit is contained in:
dualfroz committed 2026-09-03 14:06:34 +02:00
1 parent aa2b15b1b3
commit 4179793c2b
2 files changed
+29 -1

No files matched your search

+4 -1
View File
@@ -753,13 +753,16 @@ class DeepDiff(ResultDict, SerializationMixin, DistanceMixin, DeepDiffProtocol,
t_keys_removed = t1_keys - t_keys_intersect t_keys_removed = t1_keys - t_keys_intersect
if self.threshold_to_diff_deeper: if self.threshold_to_diff_deeper:
if self.exclude_paths or self.exclude_glob_paths: if self.exclude_paths or self.exclude_glob_paths or self.exclude_regex_paths:
t_keys_union = {f"{level.path()}[{repr(key)}]" for key in (t2_keys | t1_keys)} t_keys_union = {f"{level.path()}[{repr(key)}]" for key in (t2_keys | t1_keys)}
if self.exclude_paths: if self.exclude_paths:
t_keys_union -= self.exclude_paths t_keys_union -= self.exclude_paths
if self.exclude_glob_paths: if self.exclude_glob_paths:
t_keys_union = {k for k in t_keys_union t_keys_union = {k for k in t_keys_union
if not any(gp.match(k) for gp in self.exclude_glob_paths)} if not any(gp.match(k) for gp in self.exclude_glob_paths)}
if self.exclude_regex_paths:
t_keys_union = {k for k in t_keys_union
if not any(rp.search(k) for rp in self.exclude_regex_paths)}
t_keys_union_len = len(t_keys_union) t_keys_union_len = len(t_keys_union)
else: else:
t_keys_union_len = len(t2_keys | t1_keys) t_keys_union_len = len(t2_keys | t1_keys)
+25
View File
@@ -1710,6 +1710,31 @@ class TestDeepDiffText:
result = {} result = {}
assert result == ddiff assert result == ddiff
def test_skip_regexp_with_default_threshold_to_diff_deeper(self):
t1 = {'a': [1, 2, [3, {'foo1': 'bar'}]]}
t2 = {'a': [1, 2, [3, {'foo2': 'bar'}]]}
exclude_regex_paths = [r"\['foo.'\]"]
equivalent_exclude_paths = ["root['a'][2][1]['foo1']", "root['a'][2][1]['foo2']"]
# The excluded keys must not count towards threshold_to_diff_deeper, otherwise the
# whole dictionary is reported as a single change and the excluded keys leak into
# old_value and new_value.
assert {} == DeepDiff(t1, t2, exclude_regex_paths=exclude_regex_paths)
assert DeepDiff(t1, t2, exclude_paths=equivalent_exclude_paths) == DeepDiff(
t1, t2, exclude_regex_paths=exclude_regex_paths)
assert DeepDiff(t1, t2, exclude_regex_paths=exclude_regex_paths, threshold_to_diff_deeper=0) == DeepDiff(
t1, t2, exclude_regex_paths=exclude_regex_paths)
def test_skip_regexp_with_default_threshold_to_diff_deeper_reports_remaining_keys(self):
t1 = {'foo1': 'bar', 'foo2': 'x', 'foo3': 'y', 'keep': 1}
t2 = {'foo4': 'bar', 'foo5': 'x', 'foo6': 'y', 'keep': 2}
# Only 'keep' is shared, so the excluded keys alone would drag the ratio of shared
# keys under threshold_to_diff_deeper and hide the change on 'keep'.
ddiff = DeepDiff(t1, t2, exclude_regex_paths=[r"root\['foo.'\]"])
result = {'values_changed': {"root['keep']": {'new_value': 2, 'old_value': 1}}}
assert result == ddiff
def test_include_obj_callback(self): def test_include_obj_callback(self):
def include_obj_callback(obj, path): def include_obj_callback(obj, path):
return True if "include" in path or isinstance(obj, int) else False return True if "include" in path or isinstance(obj, int) else False