Skip to content

Commit 8805e7a

Browse files
fix: implement __eq__ and __ne__ for CopyOnWriteDict
Fixes equality comparison bug where CopyOnWriteDict compared equal to {} even when containing data. This caused apply_policy() to incorrectly drop valid payload modifications when plugins removed all arguments. Changes: - Add __eq__ and __ne__ methods to CopyOnWriteDict - Add 13 comprehensive equality unit tests - Add policy regression tests for empty args scenario - Add end-to-end integration tests Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>
1 parent 5ed4b3d commit 8805e7a

3 files changed

Lines changed: 251 additions & 1 deletion

File tree

cpex/framework/memory.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,47 @@ def __repr__(self) -> str:
173173
"""
174174
return f"CopyOnWriteDict({dict(self.items())})"
175175

176+
__hash__ = None
177+
178+
def __eq__(self, other: Any) -> bool:
179+
"""
180+
Compare equality with another mapping.
181+
182+
Compares the materialized logical mapping (original + modifications - deletions)
183+
rather than the empty base dict storage.
184+
185+
Args:
186+
other: The object to compare with.
187+
188+
Returns:
189+
True if other is a Mapping with the same key-value pairs, False otherwise.
190+
Returns NotImplemented for non-Mapping types to allow other.__eq__ to handle it.
191+
"""
192+
# Import here to avoid circular dependency
193+
from collections.abc import Mapping
194+
195+
if not isinstance(other, Mapping):
196+
return NotImplemented
197+
198+
# Compare materialized items
199+
return dict(self.items()) == dict(other.items())
200+
201+
def __ne__(self, other: Any) -> bool:
202+
"""
203+
Compare inequality with another mapping.
204+
205+
Args:
206+
other: The object to compare with.
207+
208+
Returns:
209+
True if not equal, False if equal.
210+
Returns NotImplemented for non-Mapping types.
211+
"""
212+
eq = self.__eq__(other)
213+
if eq is NotImplemented:
214+
return NotImplemented
215+
return not eq
216+
176217
def get(self, key: Any, default: Optional[Any] = None) -> Any:
177218
"""
178219
Get an item with a default fallback.

tests/unit/cpex/framework/test_memory.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -817,7 +817,102 @@ def test_iter_skips_deleted_keys_in_modifications(self):
817817
keys = list(cow)
818818
# Should only have b (from original) and c (from modifications, not deleted)
819819
assert set(keys) == {"b", "c"}
820-
assert "a" not in keys
820+
821+
def test_equality_with_empty_dict(self):
822+
"""CopyOnWriteDict with data should not equal empty dict."""
823+
cow = CopyOnWriteDict({"a": 1, "b": 2})
824+
assert cow != {}
825+
assert {} != cow
826+
assert not (cow == {})
827+
assert not ({} == cow)
828+
829+
def test_equality_with_matching_dict(self):
830+
"""CopyOnWriteDict should equal dict with same key-value pairs."""
831+
original = {"a": 1, "b": 2, "c": 3}
832+
cow = CopyOnWriteDict(original)
833+
assert cow == {"a": 1, "b": 2, "c": 3}
834+
assert {"a": 1, "b": 2, "c": 3} == cow
835+
836+
def test_equality_with_different_dict(self):
837+
"""CopyOnWriteDict should not equal dict with different content."""
838+
cow = CopyOnWriteDict({"a": 1, "b": 2})
839+
assert cow != {"a": 1, "b": 3}
840+
assert cow != {"a": 1}
841+
assert cow != {"a": 1, "b": 2, "c": 3}
842+
843+
def test_equality_after_modifications(self):
844+
"""Equality should reflect modifications."""
845+
cow = CopyOnWriteDict({"a": 1, "b": 2})
846+
cow["c"] = 3
847+
assert cow == {"a": 1, "b": 2, "c": 3}
848+
assert cow != {"a": 1, "b": 2}
849+
850+
def test_equality_after_deletions(self):
851+
"""Equality should reflect deletions."""
852+
cow = CopyOnWriteDict({"a": 1, "b": 2, "c": 3})
853+
del cow["b"]
854+
assert cow == {"a": 1, "c": 3}
855+
assert cow != {"a": 1, "b": 2, "c": 3}
856+
857+
def test_equality_after_override(self):
858+
"""Equality should reflect overridden values."""
859+
cow = CopyOnWriteDict({"a": 1, "b": 2})
860+
cow["a"] = 10
861+
assert cow == {"a": 10, "b": 2}
862+
assert cow != {"a": 1, "b": 2}
863+
864+
def test_equality_with_another_copyonwritedict(self):
865+
"""Two CopyOnWriteDict instances with same content should be equal."""
866+
cow1 = CopyOnWriteDict({"a": 1, "b": 2})
867+
cow2 = CopyOnWriteDict({"a": 1, "b": 2})
868+
assert cow1 == cow2
869+
assert cow2 == cow1
870+
871+
def test_equality_empty_copyonwritedict(self):
872+
"""Empty CopyOnWriteDict should equal empty dict."""
873+
cow = CopyOnWriteDict({})
874+
assert cow == {}
875+
assert {} == cow
876+
877+
def test_equality_with_non_mapping_returns_notimplemented(self):
878+
"""Equality with non-Mapping types should return NotImplemented."""
879+
cow = CopyOnWriteDict({"a": 1})
880+
# These should not raise, Python will handle NotImplemented
881+
assert cow != "not a dict"
882+
assert cow != 123
883+
assert cow != ["a", "list"]
884+
assert cow != None
885+
886+
def test_inequality_operator(self):
887+
"""Test __ne__ operator works correctly."""
888+
cow = CopyOnWriteDict({"a": 1, "b": 2})
889+
assert cow != {}
890+
assert cow != {"a": 1}
891+
assert not (cow != {"a": 1, "b": 2})
892+
893+
def test_copyonwritedict_is_unhashable(self):
894+
"""CopyOnWriteDict should remain unhashable like dict."""
895+
cow = CopyOnWriteDict({"a": 1})
896+
with pytest.raises(TypeError):
897+
hash(cow)
898+
899+
def test_equality_wxo_args_scenario(self):
900+
"""Regression test for the WXO args bug scenario."""
901+
# This is the exact scenario from the bug report
902+
cow = CopyOnWriteDict({
903+
"wxo_connection_id": "",
904+
"wxo_auth": "fake-token",
905+
"wxo_environment_id": "draft",
906+
})
907+
908+
# These were the failing assertions in the bug
909+
assert cow != {}
910+
assert {} != cow
911+
assert cow == {
912+
"wxo_connection_id": "",
913+
"wxo_auth": "fake-token",
914+
"wxo_environment_id": "draft",
915+
}
821916

822917

823918
class TestCopyOnWriteFunction:

tests/unit/cpex/framework/test_policies.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,65 @@ class PayloadWithModel(PluginPayload):
172172
assert result is not None
173173
assert result.nested.x == 99 # type: ignore[union-attr]
174174

175+
def test_copyonwritedict_args_empty_modification_preserved(self):
176+
"""Regression test for bug where CopyOnWriteDict equality caused
177+
apply_policy to drop valid empty args modification.
178+
179+
When a plugin receives args as CopyOnWriteDict with data and returns
180+
an empty dict, apply_policy should treat this as a valid modification.
181+
Previously, CopyOnWriteDict.__eq__ was not implemented, causing the
182+
comparison to use dict's default equality which compared the empty
183+
base storage, incorrectly returning True for CopyOnWriteDict({...}) == {}.
184+
"""
185+
from cpex.framework.memory import CopyOnWriteDict
186+
187+
policy = HookPayloadPolicy(writable_fields=frozenset({"args"}))
188+
189+
# Simulate plugin receiving payload with CopyOnWriteDict args
190+
original = SamplePayload(
191+
name="test",
192+
args=CopyOnWriteDict({
193+
"wxo_connection_id": "",
194+
"wxo_auth": "fake-token",
195+
"wxo_environment_id": "draft",
196+
}),
197+
secret="s",
198+
)
199+
200+
# Plugin strips all args, returning empty dict
201+
modified = SamplePayload(name="test", args={}, secret="s")
202+
203+
result = apply_policy(original, modified, policy)
204+
205+
# The modification should be preserved, not dropped
206+
assert result is not None, "apply_policy should not return None when args changed from {...} to {}"
207+
assert result.args == {} # type: ignore[union-attr]
208+
assert result.name == "test" # type: ignore[union-attr]
209+
assert result.secret == "s" # type: ignore[union-attr]
210+
211+
def test_copyonwritedict_args_partial_modification_preserved(self):
212+
"""Test that partial arg removal is also preserved correctly."""
213+
from cpex.framework.memory import CopyOnWriteDict
214+
215+
policy = HookPayloadPolicy(writable_fields=frozenset({"args"}))
216+
217+
original = SamplePayload(
218+
name="test",
219+
args=CopyOnWriteDict({
220+
"wxo_auth": "token",
221+
"real_arg": "value",
222+
}),
223+
secret="s",
224+
)
225+
226+
# Plugin removes only wxo_auth, keeping real_arg
227+
modified = SamplePayload(name="test", args={"real_arg": "value"}, secret="s")
228+
229+
result = apply_policy(original, modified, policy)
230+
231+
assert result is not None
232+
assert result.args == {"real_arg": "value"} # type: ignore[union-attr]
233+
175234

176235
class TestPluginPayloadFrozen:
177236
"""Tests for frozen PluginPayload base class."""
@@ -752,6 +811,61 @@ async def tool_pre_invoke(self, payload, context):
752811
assert result.modified_payload.secret == "safe" # Policy filtered this out
753812

754813

814+
@pytest.mark.asyncio
815+
async def test_tool_pre_invoke_empty_args_modification_preserved_through_executor(self):
816+
"""Regression test for the tool_pre_invoke executor path.
817+
818+
A plugin receives CoW-wrapped args containing only specific fields,
819+
strips them all, and returns a payload with args={}. The executor should
820+
preserve that empty args modification instead of dropping it as
821+
"unchanged".
822+
"""
823+
from cpex.framework.base import HookRef, Plugin, PluginRef
824+
from cpex.framework.hooks.policies import HookPayloadPolicy
825+
from cpex.framework.hooks.tools import ToolPreInvokePayload
826+
from cpex.framework.manager import PluginExecutor
827+
from cpex.framework.memory import CopyOnWriteDict
828+
from cpex.framework.models import GlobalContext, PluginConfig, PluginResult
829+
830+
seen_arg_types = []
831+
832+
class StripWxoArgsPlugin(Plugin):
833+
async def tool_pre_invoke(self, payload, context):
834+
seen_arg_types.append(type(payload.args))
835+
cleaned_args = {k: v for k, v in payload.args.items() if not k.startswith("wxo_")}
836+
modified = payload.model_copy(update={"args": cleaned_args})
837+
return PluginResult(continue_processing=True, modified_payload=modified)
838+
839+
policies = {
840+
"tool_pre_invoke": HookPayloadPolicy(writable_fields=frozenset({"args"})),
841+
}
842+
executor = PluginExecutor(hook_policies=policies)
843+
844+
config = PluginConfig(name="stripper", kind="test.Plugin", version="1.0", hooks=["tool_pre_invoke"])
845+
plugin = StripWxoArgsPlugin(config)
846+
hook_ref = HookRef("tool_pre_invoke", PluginRef(plugin))
847+
848+
payload = ToolPreInvokePayload(
849+
name="list_all_secrets",
850+
args={
851+
"wxo_connection_id": "",
852+
"wxo_auth": "fake-token",
853+
"wxo_environment_id": "draft",
854+
},
855+
)
856+
global_ctx = GlobalContext(request_id="tool-pre-empty-args")
857+
858+
result, _ = await executor.execute([hook_ref], payload, global_ctx, hook_type="tool_pre_invoke")
859+
860+
assert seen_arg_types == [CopyOnWriteDict]
861+
assert result.modified_payload is not None
862+
assert result.modified_payload == ToolPreInvokePayload(name="list_all_secrets", args={})
863+
assert payload.args == {
864+
"wxo_connection_id": "",
865+
"wxo_auth": "fake-token",
866+
"wxo_environment_id": "draft",
867+
}
868+
755869
class TestMultiPluginDictChain:
756870
"""Tests for multi-plugin chains where an earlier plugin returns a dict payload."""
757871

0 commit comments

Comments
 (0)