Skip to content

Commit 888ee21

Browse files
fix(entities): implement __hash__ and __repr__ for EntityInstanceId (#309)
* fix(entities): implement __hash__ and __repr__ for EntityInstanceId * fix(entities): enforce immutability on EntityInstanceId for hash stability * fix(entities): preserve subclass type when pickling
1 parent 0cf1ad4 commit 888ee21

4 files changed

Lines changed: 196 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ by one or more task hubs.
1414

1515
FIXED
1616

17+
- Implemented `__hash__` and `__repr__` for `EntityInstanceId`, making identity attributes immutable to guarantee hash stability when used in sets or dict keys.
1718
- Large-payload externalization now checks every eligible payload's maximum
1819
size before uploading or replacing any protobuf values, including carryover
1920
events and entity payloads, for both synchronous and asynchronous calls.

‎durabletask/entities/entity_instance_id.py‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,42 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33

4+
45
class EntityInstanceId:
6+
__slots__ = ("_entity", "_key")
7+
58
def __init__(self, entity: str, key: str):
69
EntityInstanceId.validate_entity_name(entity)
710
EntityInstanceId.validate_key(key)
8-
self.entity = entity.lower()
9-
self.key = key
11+
object.__setattr__(self, "_entity", entity.lower())
12+
object.__setattr__(self, "_key", key)
13+
14+
@property
15+
def entity(self) -> str:
16+
return self._entity
17+
18+
@property
19+
def key(self) -> str:
20+
return self._key
21+
22+
def __setattr__(self, name: str, value: object) -> None:
23+
raise AttributeError(
24+
f"'{type(self).__name__}' object does not support attribute assignment"
25+
)
26+
27+
def __delattr__(self, name: str) -> None:
28+
raise AttributeError(
29+
f"'{type(self).__name__}' object does not support attribute deletion"
30+
)
31+
32+
def __copy__(self) -> "EntityInstanceId":
33+
return self
34+
35+
def __deepcopy__(self, memo: object) -> "EntityInstanceId":
36+
return self
37+
38+
def __reduce__(self) -> tuple[type["EntityInstanceId"], tuple[str, str]]:
39+
return (type(self), (self._entity, self._key))
1040

1141
def __str__(self) -> str:
1242
return f"@{self.entity}@{self.key}"
@@ -21,6 +51,12 @@ def __lt__(self, other: object) -> bool:
2151
return NotImplemented
2252
return str(self) < str(other)
2353

54+
def __hash__(self) -> int:
55+
return hash((self.entity, self.key))
56+
57+
def __repr__(self) -> str:
58+
return f"EntityInstanceId(entity={self.entity!r}, key={self.key!r})"
59+
2460
@staticmethod
2561
def parse(entity_id: str) -> "EntityInstanceId":
2662
"""Parse a string representation of an entity ID into an EntityInstanceId object.

‎tests/azure-functions-durable/test_client_compat.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Licensed under the MIT License.
33

44
import json
5+
import pickle
56
from datetime import datetime, timedelta, timezone
67
from types import SimpleNamespace
78
from unittest.mock import AsyncMock, Mock, patch
@@ -927,6 +928,33 @@ def test_entity_id_maps_to_entity_instance_id():
927928
assert str(entity_id) == "@counter@one"
928929

929930

931+
def test_entity_id_pickle_round_trip():
932+
with pytest.warns(DeprecationWarning):
933+
entity_id = df.EntityId("Counter", "one")
934+
935+
restored = pickle.loads(pickle.dumps(entity_id))
936+
assert type(restored) is df.EntityId
937+
assert isinstance(restored, df.EntityId)
938+
assert restored.name == "counter"
939+
assert restored.entity == "counter"
940+
assert restored.key == "one"
941+
assert str(restored) == "@counter@one"
942+
assert restored == entity_id
943+
assert hash(restored) == hash(entity_id)
944+
945+
# Immutability guarantees remain intact after unpickling
946+
with pytest.raises(AttributeError):
947+
restored.entity = "other"
948+
with pytest.raises(AttributeError):
949+
restored.key = "other"
950+
with pytest.raises(AttributeError):
951+
restored.name = "other"
952+
with pytest.raises(AttributeError):
953+
del restored.entity
954+
with pytest.raises(AttributeError):
955+
del restored.key
956+
957+
930958
def test_entity_id_url_path():
931959
with pytest.warns(DeprecationWarning):
932960
entity_id = df.EntityId("Counter", "one")

‎tests/durabletask/entities/test_entity_id_parsing.py‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33

4+
import copy
5+
import pickle
6+
47
import pytest
58
from durabletask.entities import EntityInstanceId
69

@@ -51,3 +54,129 @@ def test_entity_id_parsing_failures():
5154
# Test multiple @ symbols
5255
with pytest.raises(ValueError):
5356
EntityInstanceId.parse("invalid@symbol@placement")
57+
58+
59+
def test_entity_instance_id_hash_and_equality():
60+
id1 = EntityInstanceId("MyEntity", "instance1")
61+
id2 = EntityInstanceId("myentity", "instance1")
62+
id3 = EntityInstanceId("myentity", "instance2")
63+
id4 = EntityInstanceId("other", "instance1")
64+
65+
assert hash(id1) == hash(id2)
66+
assert hash(id1) != hash(id3)
67+
assert hash(id1) != hash(id4)
68+
69+
70+
def test_entity_instance_id_in_set():
71+
id1 = EntityInstanceId("MyEntity", "key1")
72+
id2 = EntityInstanceId("myentity", "key1")
73+
id3 = EntityInstanceId("myentity", "key2")
74+
75+
s = {id1, id2, id3}
76+
assert len(s) == 2
77+
assert EntityInstanceId("myentity", "key1") in s
78+
assert EntityInstanceId("myentity", "key2") in s
79+
80+
81+
def test_entity_instance_id_as_dict_key():
82+
id1 = EntityInstanceId("Counter", "key1")
83+
id2 = EntityInstanceId("counter", "key1")
84+
85+
mapping = {id1: "value1"}
86+
assert mapping[id2] == "value1"
87+
mapping[id2] = "value2"
88+
assert len(mapping) == 1
89+
assert mapping[id1] == "value2"
90+
91+
92+
def test_entity_instance_id_repr():
93+
eid = EntityInstanceId("MyEntity", "InstanceKey")
94+
expected = "EntityInstanceId(entity='myentity', key='InstanceKey')"
95+
assert repr(eid) == expected
96+
97+
98+
def test_entity_instance_id_immutable():
99+
"""Verify that entity and key cannot be reassigned or deleted after construction,
100+
ensuring hash stability when instances are stored in sets or dicts."""
101+
eid = EntityInstanceId("Counter", "key1")
102+
103+
with pytest.raises(AttributeError):
104+
eid.entity = "other"
105+
106+
with pytest.raises(AttributeError):
107+
eid.key = "other_key"
108+
109+
with pytest.raises(AttributeError):
110+
eid.custom_attr = "value"
111+
112+
with pytest.raises(AttributeError):
113+
del eid.entity
114+
115+
with pytest.raises(AttributeError):
116+
del eid.key
117+
118+
# Verify the original values are unchanged
119+
assert eid.entity == "counter"
120+
assert eid.key == "key1"
121+
122+
123+
def test_entity_instance_id_immutable_dict_stability():
124+
"""Verify that immutability preserves dict/set lookup integrity."""
125+
eid = EntityInstanceId("Counter", "key1")
126+
mapping = {eid: "value"}
127+
s = {eid}
128+
129+
# Attempt mutation - should be rejected
130+
with pytest.raises(AttributeError):
131+
eid.entity = "other"
132+
133+
# Lookups with an equal instance must still succeed
134+
lookup = EntityInstanceId("counter", "key1")
135+
assert mapping[lookup] == "value"
136+
assert lookup in s
137+
138+
139+
def test_entity_instance_id_copy_and_pickle():
140+
"""Verify that immutable EntityInstanceId instances can be copied and pickled."""
141+
eid = EntityInstanceId("Counter", "key1")
142+
143+
# Copying should preserve identity / immutability
144+
assert copy.copy(eid) is eid
145+
assert copy.deepcopy(eid) is eid
146+
147+
# Pickling should preserve equality and hash
148+
pickled = pickle.loads(pickle.dumps(eid))
149+
assert pickled == eid
150+
assert hash(pickled) == hash(eid)
151+
assert pickled.entity == "counter"
152+
assert pickled.key == "key1"
153+
154+
155+
class _SubclassEntityId(EntityInstanceId):
156+
@property
157+
def custom_name(self) -> str:
158+
return self.entity.upper()
159+
160+
161+
def test_entity_instance_id_subclass_pickle():
162+
"""Verify that unpickling a subclass of EntityInstanceId preserves the concrete type."""
163+
custom = _SubclassEntityId("Counter", "key1")
164+
assert isinstance(custom, _SubclassEntityId)
165+
assert custom.custom_name == "COUNTER"
166+
167+
restored = pickle.loads(pickle.dumps(custom))
168+
assert type(restored) is _SubclassEntityId
169+
assert isinstance(restored, _SubclassEntityId)
170+
assert restored.custom_name == "COUNTER"
171+
assert restored.entity == "counter"
172+
assert restored.key == "key1"
173+
assert restored == custom
174+
assert hash(restored) == hash(custom)
175+
176+
# Immutability guarantees remain intact after unpickling
177+
with pytest.raises(AttributeError):
178+
restored.entity = "other"
179+
with pytest.raises(AttributeError):
180+
restored.key = "other"
181+
with pytest.raises(AttributeError):
182+
del restored.entity

0 commit comments

Comments
 (0)