From 2dbbb6d4d8c3e8c9ea585c000a6b2cb80f09b30c Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Thu, 22 Oct 2026 12:05:45 +0530 Subject: [PATCH 1/3] fix(entities): implement __hash__ and __repr__ for EntityInstanceId --- CHANGELOG.md | 1 + durabletask/entities/entity_instance_id.py | 6 +++ .../entities/test_entity_id_parsing.py | 39 +++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ad5e0bc..08b021d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ by one or more task hubs. FIXED +- Implemented `__hash__` and `__repr__` for `EntityInstanceId`, enabling usage in sets, dict keys, and clear debug representations. - Large-payload externalization now checks every eligible payload's maximum size before uploading or replacing any protobuf values, including carryover events and entity payloads, for both synchronous and asynchronous calls. diff --git a/durabletask/entities/entity_instance_id.py b/durabletask/entities/entity_instance_id.py index 9c984877..0a74b913 100644 --- a/durabletask/entities/entity_instance_id.py +++ b/durabletask/entities/entity_instance_id.py @@ -21,6 +21,12 @@ def __lt__(self, other: object) -> bool: return NotImplemented return str(self) < str(other) + def __hash__(self) -> int: + return hash((self.entity, self.key)) + + def __repr__(self) -> str: + return f"EntityInstanceId(entity={self.entity!r}, key={self.key!r})" + @staticmethod def parse(entity_id: str) -> "EntityInstanceId": """Parse a string representation of an entity ID into an EntityInstanceId object. diff --git a/tests/durabletask/entities/test_entity_id_parsing.py b/tests/durabletask/entities/test_entity_id_parsing.py index 77bb339e..e403f4e0 100644 --- a/tests/durabletask/entities/test_entity_id_parsing.py +++ b/tests/durabletask/entities/test_entity_id_parsing.py @@ -51,3 +51,42 @@ def test_entity_id_parsing_failures(): # Test multiple @ symbols with pytest.raises(ValueError): EntityInstanceId.parse("invalid@symbol@placement") + + +def test_entity_instance_id_hash_and_equality(): + id1 = EntityInstanceId("MyEntity", "instance1") + id2 = EntityInstanceId("myentity", "instance1") + id3 = EntityInstanceId("myentity", "instance2") + id4 = EntityInstanceId("other", "instance1") + + assert hash(id1) == hash(id2) + assert hash(id1) != hash(id3) + assert hash(id1) != hash(id4) + + +def test_entity_instance_id_in_set(): + id1 = EntityInstanceId("MyEntity", "key1") + id2 = EntityInstanceId("myentity", "key1") + id3 = EntityInstanceId("myentity", "key2") + + s = {id1, id2, id3} + assert len(s) == 2 + assert EntityInstanceId("myentity", "key1") in s + assert EntityInstanceId("myentity", "key2") in s + + +def test_entity_instance_id_as_dict_key(): + id1 = EntityInstanceId("Counter", "key1") + id2 = EntityInstanceId("counter", "key1") + + mapping = {id1: "value1"} + assert mapping[id2] == "value1" + mapping[id2] = "value2" + assert len(mapping) == 1 + assert mapping[id1] == "value2" + + +def test_entity_instance_id_repr(): + eid = EntityInstanceId("MyEntity", "InstanceKey") + expected = "EntityInstanceId(entity='myentity', key='InstanceKey')" + assert repr(eid) == expected From 03aa06a9997c15b1b4e5448e29786f222c454df0 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Fri, 23 Oct 2026 15:17:00 +0530 Subject: [PATCH 2/3] fix(entities): enforce immutability on EntityInstanceId for hash stability --- CHANGELOG.md | 2 +- durabletask/entities/entity_instance_id.py | 34 ++++++++++- .../entities/test_entity_id_parsing.py | 60 +++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b021d5..ed05ce15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ by one or more task hubs. FIXED -- Implemented `__hash__` and `__repr__` for `EntityInstanceId`, enabling usage in sets, dict keys, and clear debug representations. +- Implemented `__hash__` and `__repr__` for `EntityInstanceId`, making identity attributes immutable to guarantee hash stability when used in sets or dict keys. - Large-payload externalization now checks every eligible payload's maximum size before uploading or replacing any protobuf values, including carryover events and entity payloads, for both synchronous and asynchronous calls. diff --git a/durabletask/entities/entity_instance_id.py b/durabletask/entities/entity_instance_id.py index 0a74b913..3327c9bf 100644 --- a/durabletask/entities/entity_instance_id.py +++ b/durabletask/entities/entity_instance_id.py @@ -1,12 +1,42 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. + class EntityInstanceId: + __slots__ = ("_entity", "_key") + def __init__(self, entity: str, key: str): EntityInstanceId.validate_entity_name(entity) EntityInstanceId.validate_key(key) - self.entity = entity.lower() - self.key = key + object.__setattr__(self, "_entity", entity.lower()) + object.__setattr__(self, "_key", key) + + @property + def entity(self) -> str: + return self._entity + + @property + def key(self) -> str: + return self._key + + def __setattr__(self, name: str, value: object) -> None: + raise AttributeError( + f"'{type(self).__name__}' object does not support attribute assignment" + ) + + def __delattr__(self, name: str) -> None: + raise AttributeError( + f"'{type(self).__name__}' object does not support attribute deletion" + ) + + def __copy__(self) -> "EntityInstanceId": + return self + + def __deepcopy__(self, memo: object) -> "EntityInstanceId": + return self + + def __reduce__(self) -> tuple[type["EntityInstanceId"], tuple[str, str]]: + return (EntityInstanceId, (self._entity, self._key)) def __str__(self) -> str: return f"@{self.entity}@{self.key}" diff --git a/tests/durabletask/entities/test_entity_id_parsing.py b/tests/durabletask/entities/test_entity_id_parsing.py index e403f4e0..6ad8fef4 100644 --- a/tests/durabletask/entities/test_entity_id_parsing.py +++ b/tests/durabletask/entities/test_entity_id_parsing.py @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import copy +import pickle + import pytest from durabletask.entities import EntityInstanceId @@ -90,3 +93,60 @@ def test_entity_instance_id_repr(): eid = EntityInstanceId("MyEntity", "InstanceKey") expected = "EntityInstanceId(entity='myentity', key='InstanceKey')" assert repr(eid) == expected + + +def test_entity_instance_id_immutable(): + """Verify that entity and key cannot be reassigned or deleted after construction, + ensuring hash stability when instances are stored in sets or dicts.""" + eid = EntityInstanceId("Counter", "key1") + + with pytest.raises(AttributeError): + eid.entity = "other" + + with pytest.raises(AttributeError): + eid.key = "other_key" + + with pytest.raises(AttributeError): + eid.custom_attr = "value" + + with pytest.raises(AttributeError): + del eid.entity + + with pytest.raises(AttributeError): + del eid.key + + # Verify the original values are unchanged + assert eid.entity == "counter" + assert eid.key == "key1" + + +def test_entity_instance_id_immutable_dict_stability(): + """Verify that immutability preserves dict/set lookup integrity.""" + eid = EntityInstanceId("Counter", "key1") + mapping = {eid: "value"} + s = {eid} + + # Attempt mutation - should be rejected + with pytest.raises(AttributeError): + eid.entity = "other" + + # Lookups with an equal instance must still succeed + lookup = EntityInstanceId("counter", "key1") + assert mapping[lookup] == "value" + assert lookup in s + + +def test_entity_instance_id_copy_and_pickle(): + """Verify that immutable EntityInstanceId instances can be copied and pickled.""" + eid = EntityInstanceId("Counter", "key1") + + # Copying should preserve identity / immutability + assert copy.copy(eid) is eid + assert copy.deepcopy(eid) is eid + + # Pickling should preserve equality and hash + pickled = pickle.loads(pickle.dumps(eid)) + assert pickled == eid + assert hash(pickled) == hash(eid) + assert pickled.entity == "counter" + assert pickled.key == "key1" From 83948ee9d0e6f00846c15f43a99ea0eb449f4e37 Mon Sep 17 00:00:00 2001 From: Hardik Kaurani Date: Fri, 23 Oct 2026 23:44:37 +0530 Subject: [PATCH 3/3] fix(entities): preserve subclass type when pickling --- durabletask/entities/entity_instance_id.py | 2 +- .../test_client_compat.py | 28 +++++++++++++++++ .../entities/test_entity_id_parsing.py | 30 +++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/durabletask/entities/entity_instance_id.py b/durabletask/entities/entity_instance_id.py index 3327c9bf..e195736c 100644 --- a/durabletask/entities/entity_instance_id.py +++ b/durabletask/entities/entity_instance_id.py @@ -36,7 +36,7 @@ def __deepcopy__(self, memo: object) -> "EntityInstanceId": return self def __reduce__(self) -> tuple[type["EntityInstanceId"], tuple[str, str]]: - return (EntityInstanceId, (self._entity, self._key)) + return (type(self), (self._entity, self._key)) def __str__(self) -> str: return f"@{self.entity}@{self.key}" diff --git a/tests/azure-functions-durable/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py index 8cf96308..6be86e97 100644 --- a/tests/azure-functions-durable/test_client_compat.py +++ b/tests/azure-functions-durable/test_client_compat.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. import json +import pickle from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch @@ -927,6 +928,33 @@ def test_entity_id_maps_to_entity_instance_id(): assert str(entity_id) == "@counter@one" +def test_entity_id_pickle_round_trip(): + with pytest.warns(DeprecationWarning): + entity_id = df.EntityId("Counter", "one") + + restored = pickle.loads(pickle.dumps(entity_id)) + assert type(restored) is df.EntityId + assert isinstance(restored, df.EntityId) + assert restored.name == "counter" + assert restored.entity == "counter" + assert restored.key == "one" + assert str(restored) == "@counter@one" + assert restored == entity_id + assert hash(restored) == hash(entity_id) + + # Immutability guarantees remain intact after unpickling + with pytest.raises(AttributeError): + restored.entity = "other" + with pytest.raises(AttributeError): + restored.key = "other" + with pytest.raises(AttributeError): + restored.name = "other" + with pytest.raises(AttributeError): + del restored.entity + with pytest.raises(AttributeError): + del restored.key + + def test_entity_id_url_path(): with pytest.warns(DeprecationWarning): entity_id = df.EntityId("Counter", "one") diff --git a/tests/durabletask/entities/test_entity_id_parsing.py b/tests/durabletask/entities/test_entity_id_parsing.py index 6ad8fef4..1a9fb942 100644 --- a/tests/durabletask/entities/test_entity_id_parsing.py +++ b/tests/durabletask/entities/test_entity_id_parsing.py @@ -150,3 +150,33 @@ def test_entity_instance_id_copy_and_pickle(): assert hash(pickled) == hash(eid) assert pickled.entity == "counter" assert pickled.key == "key1" + + +class _SubclassEntityId(EntityInstanceId): + @property + def custom_name(self) -> str: + return self.entity.upper() + + +def test_entity_instance_id_subclass_pickle(): + """Verify that unpickling a subclass of EntityInstanceId preserves the concrete type.""" + custom = _SubclassEntityId("Counter", "key1") + assert isinstance(custom, _SubclassEntityId) + assert custom.custom_name == "COUNTER" + + restored = pickle.loads(pickle.dumps(custom)) + assert type(restored) is _SubclassEntityId + assert isinstance(restored, _SubclassEntityId) + assert restored.custom_name == "COUNTER" + assert restored.entity == "counter" + assert restored.key == "key1" + assert restored == custom + assert hash(restored) == hash(custom) + + # Immutability guarantees remain intact after unpickling + with pytest.raises(AttributeError): + restored.entity = "other" + with pytest.raises(AttributeError): + restored.key = "other" + with pytest.raises(AttributeError): + del restored.entity