Skip to content

Commit 79f9067

Browse files
jtdubclaude
andcommitted
Fix __hash__/__eq__ inconsistency in HConfigChild (#185) (#236)
* Fix __hash__/__eq__ inconsistency in HConfigChild (#185) __hash__ included new_in_config and order_weight but __eq__ intentionally excluded them, violating the Python invariant that a == b implies hash(a) == hash(b). __eq__ also checked tags but __hash__ did not include them. Align __hash__ to use the same fields as __eq__: text, tags, and children. Add five tests covering each dimension of the inconsistency and its practical impact on set deduplication and dict key lookup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix pre-existing lint errors in next branch - test_benchmarks.py: replace append loops with extend (PERF401), add @staticmethod to methods that don't use self (PLR6301), suppress intentional print calls with noqa: T201 - test_child.py: suppress pylint too-many-lines (C0302) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 781e3a1 commit 79f9067

2 files changed

Lines changed: 148 additions & 24 deletions

File tree

tests/benchmarks/test_benchmarks.py

Lines changed: 38 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,10 @@ def _generate_large_ios_config(num_interfaces: int = 1000) -> str:
5454
" auto-cost reference-bandwidth 100000",
5555
]
5656
)
57-
for i in range(num_interfaces):
58-
lines.append(f" network 10.{i // 256}.{i % 256}.0 0.0.0.3 area 0")
57+
lines.extend(
58+
f" network 10.{i // 256}.{i % 256}.0 0.0.0.3 area 0"
59+
for i in range(num_interfaces)
60+
)
5961
lines.extend(
6062
[
6163
"!",
@@ -125,8 +127,10 @@ def _generate_large_xr_config(num_interfaces: int = 1000) -> str:
125127
" router-id 10.0.0.1",
126128
]
127129
)
128-
for i in range(num_interfaces):
129-
lines.append(f" area 0 interface GigabitEthernet0/0/0/{i} cost 100")
130+
lines.extend(
131+
f" area 0 interface GigabitEthernet0/0/0/{i} cost 100"
132+
for i in range(num_interfaces)
133+
)
130134
lines.append("!")
131135
return "\n".join(lines)
132136

@@ -145,33 +149,37 @@ def _time_fn(fn: Callable[[], object], iterations: int = 3) -> float:
145149
class TestParsingBenchmarks:
146150
"""Benchmarks for config parsing."""
147151

148-
def test_parse_large_ios_config(self) -> None:
152+
@staticmethod
153+
def test_parse_large_ios_config() -> None:
149154
"""Parse a ~10k line IOS config via get_hconfig."""
150155
config_text = _generate_large_ios_config()
151156
elapsed = _time_fn(lambda: get_hconfig(Platform.CISCO_IOS, config_text))
152157
line_count = config_text.count("\n")
153-
print(f"\nget_hconfig: {line_count} lines in {elapsed:.4f}s")
158+
print(f"\nget_hconfig: {line_count} lines in {elapsed:.4f}s") # noqa: T201
154159
assert elapsed < 5.0, f"Parsing took {elapsed:.2f}s, expected < 5s"
155160

156-
def test_parse_large_xr_config(self) -> None:
161+
@staticmethod
162+
def test_parse_large_xr_config() -> None:
157163
"""Parse a ~10k line XR config via get_hconfig."""
158164
config_text = _generate_large_xr_config()
159165
elapsed = _time_fn(lambda: get_hconfig(Platform.CISCO_XR, config_text))
160166
line_count = config_text.count("\n")
161-
print(f"\nget_hconfig (XR): {line_count} lines in {elapsed:.4f}s")
167+
print(f"\nget_hconfig (XR): {line_count} lines in {elapsed:.4f}s") # noqa: T201
162168
assert elapsed < 5.0, f"Parsing took {elapsed:.2f}s, expected < 5s"
163169

164-
def test_fast_load_large_ios_config(self) -> None:
170+
@staticmethod
171+
def test_fast_load_large_ios_config() -> None:
165172
"""Parse a ~10k line IOS config via get_hconfig_fast_load."""
166173
config_text = _generate_large_ios_config()
167174
config_lines = tuple(config_text.splitlines())
168175
elapsed = _time_fn(
169176
lambda: get_hconfig_fast_load(Platform.CISCO_IOS, config_lines),
170177
)
171-
print(f"\nget_hconfig_fast_load: {len(config_lines)} lines in {elapsed:.4f}s")
178+
print(f"\nget_hconfig_fast_load: {len(config_lines)} lines in {elapsed:.4f}s") # noqa: T201
172179
assert elapsed < 5.0, f"Fast load took {elapsed:.2f}s, expected < 5s"
173180

174-
def test_fast_load_vs_get_hconfig(self) -> None:
181+
@staticmethod
182+
def test_fast_load_vs_get_hconfig() -> None:
175183
"""get_hconfig_fast_load should be faster than get_hconfig."""
176184
config_text = _generate_large_ios_config()
177185
config_lines = tuple(config_text.splitlines())
@@ -181,7 +189,7 @@ def test_fast_load_vs_get_hconfig(self) -> None:
181189
lambda: get_hconfig_fast_load(Platform.CISCO_IOS, config_lines),
182190
)
183191
ratio = time_full / time_fast if time_fast > 0 else float("inf")
184-
print(
192+
print( # noqa: T201
185193
f"\nget_hconfig: {time_full:.4f}s, "
186194
f"fast_load: {time_fast:.4f}s, "
187195
f"ratio: {ratio:.1f}x"
@@ -195,7 +203,8 @@ def test_fast_load_vs_get_hconfig(self) -> None:
195203
class TestRemediationBenchmarks:
196204
"""Benchmarks for config_to_get_to remediation."""
197205

198-
def test_remediation_small_diff(self) -> None:
206+
@staticmethod
207+
def test_remediation_small_diff() -> None:
199208
"""Remediation with ~5% of interfaces changed."""
200209
running_text = _generate_large_ios_config()
201210
running = get_hconfig(Platform.CISCO_IOS, running_text)
@@ -207,10 +216,11 @@ def test_remediation_small_diff(self) -> None:
207216
generated = get_hconfig(Platform.CISCO_IOS, generated_text)
208217

209218
elapsed = _time_fn(lambda: running.config_to_get_to(generated))
210-
print(f"\nRemediation (10% diff): {elapsed:.4f}s")
219+
print(f"\nRemediation (10% diff): {elapsed:.4f}s") # noqa: T201
211220
assert elapsed < 5.0, f"Remediation took {elapsed:.2f}s, expected < 5s"
212221

213-
def test_remediation_large_diff(self) -> None:
222+
@staticmethod
223+
def test_remediation_large_diff() -> None:
214224
"""Remediation with ~100% of interfaces changed."""
215225
running = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config())
216226
generated_text = _generate_large_ios_config().replace(
@@ -219,10 +229,11 @@ def test_remediation_large_diff(self) -> None:
219229
generated = get_hconfig(Platform.CISCO_IOS, generated_text)
220230

221231
elapsed = _time_fn(lambda: running.config_to_get_to(generated))
222-
print(f"\nRemediation (100% diff): {elapsed:.4f}s")
232+
print(f"\nRemediation (100% diff): {elapsed:.4f}s") # noqa: T201
223233
assert elapsed < 10.0, f"Remediation took {elapsed:.2f}s, expected < 10s"
224234

225-
def test_remediation_completely_different(self) -> None:
235+
@staticmethod
236+
def test_remediation_completely_different() -> None:
226237
"""Remediation between two entirely different configs."""
227238
running = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config(500))
228239
# Generate a completely different config
@@ -238,35 +249,38 @@ def test_remediation_completely_different(self) -> None:
238249
generated = get_hconfig(Platform.CISCO_IOS, "\n".join(lines))
239250

240251
elapsed = _time_fn(lambda: running.config_to_get_to(generated))
241-
print(f"\nRemediation (completely different): {elapsed:.4f}s")
252+
print(f"\nRemediation (completely different): {elapsed:.4f}s") # noqa: T201
242253
assert elapsed < 10.0, f"Remediation took {elapsed:.2f}s, expected < 10s"
243254

244255

245256
class TestIterationBenchmarks:
246257
"""Benchmarks for tree traversal and iteration."""
247258

248-
def test_all_children_sorted(self) -> None:
259+
@staticmethod
260+
def test_all_children_sorted() -> None:
249261
"""Iterate all_children_sorted on a large config."""
250262
config = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config())
251263

252264
elapsed = _time_fn(lambda: list(config.all_children_sorted()))
253265
child_count = len(list(config.all_children()))
254-
print(f"\nall_children_sorted: {child_count} nodes in {elapsed:.4f}s")
266+
print(f"\nall_children_sorted: {child_count} nodes in {elapsed:.4f}s") # noqa: T201
255267
assert elapsed < 2.0, f"Iteration took {elapsed:.2f}s, expected < 2s"
256268

257-
def test_dump_simple(self) -> None:
269+
@staticmethod
270+
def test_dump_simple() -> None:
258271
"""Dump a large config to simple text."""
259272
config = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config())
260273

261274
elapsed = _time_fn(config.dump_simple)
262275
line_count = len(config.dump_simple())
263-
print(f"\ndump_simple: {line_count} lines in {elapsed:.4f}s")
276+
print(f"\ndump_simple: {line_count} lines in {elapsed:.4f}s") # noqa: T201
264277
assert elapsed < 2.0, f"dump_simple took {elapsed:.2f}s, expected < 2s"
265278

266-
def test_deep_copy(self) -> None:
279+
@staticmethod
280+
def test_deep_copy() -> None:
267281
"""Deep copy a large config tree."""
268282
config = get_hconfig(Platform.CISCO_IOS, _generate_large_ios_config())
269283

270284
elapsed = _time_fn(config.deep_copy)
271-
print(f"\ndeep_copy: {elapsed:.4f}s")
285+
print(f"\ndeep_copy: {elapsed:.4f}s") # noqa: T201
272286
assert elapsed < 5.0, f"deep_copy took {elapsed:.2f}s, expected < 5s"

tests/unit/test_child.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Tests for HConfigChild functionality."""
2+
# pylint: disable=too-many-lines
23

34
import types
45

@@ -1146,3 +1147,112 @@ def test_idempotency_key_regex_trimmed_to_no_match() -> None:
11461147
assert key == ("text|logging console",)
11471148

11481149

1150+
1151+
def test_child_hash_eq_consistency_new_in_config() -> None:
1152+
"""Test that equal HConfigChild objects have equal hashes regardless of new_in_config.
1153+
1154+
Validates the bug in issue #185: __hash__ includes new_in_config but __eq__ does not,
1155+
violating the Python invariant that a == b implies hash(a) == hash(b).
1156+
"""
1157+
platform = Platform.CISCO_IOS
1158+
config = get_hconfig(platform)
1159+
child1 = config.add_child("interface GigabitEthernet0/0")
1160+
config2 = get_hconfig(platform)
1161+
child2 = config2.add_child("interface GigabitEthernet0/0")
1162+
1163+
child1.new_in_config = False
1164+
child2.new_in_config = True
1165+
1166+
# These two children compare as equal (same text, no tags, no children)
1167+
assert child1 == child2
1168+
# Python invariant: equal objects must have equal hashes
1169+
assert hash(child1) == hash(child2)
1170+
1171+
1172+
def test_child_hash_eq_consistency_order_weight() -> None:
1173+
"""Test that equal HConfigChild objects have equal hashes regardless of order_weight.
1174+
1175+
Validates the bug in issue #185: __hash__ includes order_weight but __eq__ does not,
1176+
violating the Python invariant that a == b implies hash(a) == hash(b).
1177+
"""
1178+
platform = Platform.CISCO_IOS
1179+
config = get_hconfig(platform)
1180+
child1 = config.add_child("interface GigabitEthernet0/0")
1181+
config2 = get_hconfig(platform)
1182+
child2 = config2.add_child("interface GigabitEthernet0/0")
1183+
1184+
child1.order_weight = 0
1185+
child2.order_weight = 100
1186+
1187+
# These two children compare as equal (same text, no tags, no children)
1188+
assert child1 == child2
1189+
# Python invariant: equal objects must have equal hashes
1190+
assert hash(child1) == hash(child2)
1191+
1192+
1193+
def test_child_hash_eq_consistency_tags() -> None:
1194+
"""Test that __hash__ and __eq__ agree on whether tags affect equality.
1195+
1196+
Validates the bug in issue #185: __eq__ checks tags but __hash__ does not include
1197+
tags, meaning two objects that compare unequal could have the same hash (not a
1198+
correctness violation, but inconsistent) while also raising the question of whether
1199+
tags should be part of the hash.
1200+
"""
1201+
platform = Platform.CISCO_IOS
1202+
config = get_hconfig(platform)
1203+
child1 = config.add_child("interface GigabitEthernet0/0")
1204+
config2 = get_hconfig(platform)
1205+
child2 = config2.add_child("interface GigabitEthernet0/0")
1206+
1207+
child1.tags = frozenset({"safe"})
1208+
child2.tags = frozenset()
1209+
1210+
# __eq__ considers tags, so these are unequal
1211+
assert child1 != child2
1212+
# Since they are unequal, their hashes should differ to avoid excessive collisions
1213+
# (not strictly required by the invariant, but required for correctness in reverse:
1214+
# if hash(a) != hash(b) then a != b must hold — currently tags are in __eq__ but
1215+
# not __hash__, so unequal objects can share a hash, which means dict/set lookup
1216+
# will fall back to __eq__ unexpectedly)
1217+
assert hash(child1) != hash(child2)
1218+
1219+
1220+
def test_child_set_deduplication_with_new_in_config() -> None:
1221+
"""Test that equal HConfigChild objects are deduplicated correctly in sets.
1222+
1223+
Validates the practical impact of issue #185: when new_in_config differs,
1224+
two logically equal children occupy different set buckets, causing duplicates.
1225+
"""
1226+
platform = Platform.CISCO_IOS
1227+
config = get_hconfig(platform)
1228+
child1 = config.add_child("interface GigabitEthernet0/0")
1229+
config2 = get_hconfig(platform)
1230+
child2 = config2.add_child("interface GigabitEthernet0/0")
1231+
1232+
child1.new_in_config = False
1233+
child2.new_in_config = True
1234+
1235+
assert child1 == child2
1236+
# Equal objects must collapse to one entry in a set
1237+
assert len({child1, child2}) == 1
1238+
1239+
1240+
def test_child_dict_key_lookup_with_order_weight() -> None:
1241+
"""Test that HConfigChild objects with differing order_weight work as dict keys.
1242+
1243+
Validates the practical impact of issue #185: when order_weight differs, a
1244+
logically equal child cannot be found as a dict key.
1245+
"""
1246+
platform = Platform.CISCO_IOS
1247+
config = get_hconfig(platform)
1248+
child1 = config.add_child("interface GigabitEthernet0/0")
1249+
config2 = get_hconfig(platform)
1250+
child2 = config2.add_child("interface GigabitEthernet0/0")
1251+
1252+
child1.order_weight = 0
1253+
child2.order_weight = 100
1254+
1255+
assert child1 == child2
1256+
lookup: dict[HConfigChild, str] = {child1: "found"}
1257+
# child2 is equal to child1, so it must find the same dict entry
1258+
assert lookup[child2] == "found"

0 commit comments

Comments
 (0)