Skip to content

Commit b937e1f

Browse files
author
Matthias Zimmermann
committed
add arkiv.delete_entity functionality
1 parent 25236c9 commit b937e1f

File tree

5 files changed

+269
-26
lines changed

5 files changed

+269
-26
lines changed

src/arkiv/module.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
PAYLOAD,
1919
Annotations,
2020
CreateOp,
21+
DeleteOp,
2122
Entity,
2223
EntityKey,
2324
Operations,
@@ -53,7 +54,7 @@ def __init__(self, client: "Arkiv") -> None:
5354
logger.debug(f"Entity event {event.topic}: {event.signature}")
5455

5556
def is_available(self) -> bool:
56-
"""Check if Arkiv functionality is available."""
57+
"""Check if Arkiv functionality is available. Should always be true for Arkiv clients."""
5758
return True
5859

5960
def execute(
@@ -159,22 +160,35 @@ def create_entities(
159160
entity_keys = [create.entity_key for create in receipt.creates]
160161
return entity_keys, receipt.tx_hash
161162

162-
def entity_exists(self, entity_key: EntityKey) -> bool:
163+
def delete_entity(
164+
self,
165+
entity_key: EntityKey,
166+
tx_params: TxParams | None = None,
167+
) -> TxHash:
163168
"""
164-
Check if an entity exists storage.
169+
Delete an entity from the Arkiv storage contract.
165170
166171
Args:
167-
entity_key: The entity key to check
172+
entity_key: The entity key to delete
173+
tx_params: Optional additional transaction parameters
168174
169175
Returns:
170-
True if the entity exists, False otherwise
176+
Transaction hash of the delete operation
171177
"""
172-
try:
173-
self.client.eth.get_entity_metadata(entity_key) # type: ignore[attr-defined]
174-
return True
178+
# Create the delete operation
179+
delete_op = DeleteOp(entity_key=entity_key)
175180

176-
except Exception:
177-
return False
181+
# Wrap in Operations container and execute
182+
operations = Operations(deletes=[delete_op])
183+
receipt = self.execute(operations, tx_params)
184+
185+
# Verify the delete succeeded
186+
if len(receipt.deletes) != 1:
187+
raise RuntimeError(
188+
f"Expected 1 delete in receipt, got {len(receipt.deletes)}"
189+
)
190+
191+
return receipt.tx_hash
178192

179193
def transfer_eth(
180194
self,
@@ -216,6 +230,23 @@ def transfer_eth(
216230

217231
return tx_hash
218232

233+
def entity_exists(self, entity_key: EntityKey) -> bool:
234+
"""
235+
Check if an entity exists storage.
236+
237+
Args:
238+
entity_key: The entity key to check
239+
240+
Returns:
241+
True if the entity exists, False otherwise
242+
"""
243+
try:
244+
self.client.eth.get_entity_metadata(entity_key) # type: ignore[attr-defined]
245+
return True
246+
247+
except Exception:
248+
return False
249+
219250
def get_entity(self, entity_key: EntityKey, fields: int = ALL) -> Entity:
220251
"""
221252
Get an entity by its entity key.

src/arkiv/utils.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,17 +169,18 @@ def to_receipt(
169169
event_name = event_data["event"]
170170

171171
entity_key: EntityKey = to_entity_key(event_args["entityKey"])
172-
expiration_block: int = event_args["expirationBlock"]
173172

174173
match event_name:
175174
case contract.CREATED_EVENT:
175+
expiration_block: int = event_args["expirationBlock"]
176176
creates.append(
177177
CreateReceipt(
178178
entity_key=entity_key,
179179
expiration_block=expiration_block,
180180
)
181181
)
182182
case contract.UPDATED_EVENT:
183+
expiration_block = event_args["expirationBlock"]
183184
updates.append(
184185
UpdateReceipt(
185186
entity_key=entity_key,
@@ -254,12 +255,7 @@ def rlp_encode_transaction(tx: Operations) -> bytes:
254255
for element in tx.updates
255256
],
256257
# Delete
257-
[
258-
[
259-
entity_key_to_bytes(element.entity_key),
260-
]
261-
for element in tx.deletes
262-
],
258+
[entity_key_to_bytes(element.entity_key) for element in tx.deletes],
263259
# Extend
264260
[
265261
[

tests/test_arkiv_create.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ class TestArkivClientCreation:
2121

2222
def test_create_arkiv_without_provider(self) -> None:
2323
"""Test creating Arkiv client without provider."""
24-
client = Arkiv()
25-
_assert_arkiv_client_properties(client, None, "Without Provider")
26-
logger.info("Created Arkiv client without provider")
24+
with Arkiv() as client:
25+
_assert_arkiv_client_properties(client, None, "Without Provider")
26+
logger.info("Created Arkiv client without provider")
2727

2828
def test_create_arkiv_with_http_provider(self, arkiv_node) -> None:
2929
"""Test creating Arkiv client with HTTP provider."""
@@ -136,14 +136,14 @@ def test_repr_disconnected(self) -> None:
136136

137137
def test_repr_connected_with_defaults(self) -> None:
138138
"""Test repr of connected client with defaults."""
139-
client = Arkiv()
140-
repr_str = repr(client)
139+
with Arkiv() as client:
140+
repr_str = repr(client)
141141

142-
assert isinstance(repr_str, str), "Should return string"
143-
assert "Arkiv" in repr_str, "Should contain class name"
144-
assert "connected=True" in repr_str, "Should show connection status"
142+
assert isinstance(repr_str, str), "Should return string"
143+
assert "Arkiv" in repr_str, "Should contain class name"
144+
assert "connected=True" in repr_str, "Should show connection status"
145145

146-
logger.info(f"Connected client repr: {repr_str}")
146+
logger.info(f"Connected client repr: {repr_str}")
147147

148148
def test_repr_connected_with_provider(self, arkiv_node) -> None:
149149
"""Test repr of connected client."""

tests/test_entity_delete.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""Tests for entity deletion functionality in ArkivModule."""
2+
3+
import logging
4+
5+
import pytest
6+
7+
from arkiv.client import Arkiv
8+
from arkiv.types import Annotations, CreateOp, DeleteOp, Operations
9+
10+
from .utils import check_tx_hash
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class TestEntityDelete:
16+
"""Test cases for delete_entity function."""
17+
18+
def test_delete_entity_simple(self, arkiv_client_http: Arkiv) -> None:
19+
"""Test deleting a single entity."""
20+
# First, create an entity to delete
21+
payload = b"Test entity for deletion"
22+
annotations: Annotations = Annotations({"type": "test", "purpose": "deletion"})
23+
btl = 100
24+
25+
entity_key, _ = arkiv_client_http.arkiv.create_entity(
26+
payload=payload, annotations=annotations, btl=btl
27+
)
28+
29+
logger.info(f"Created entity {entity_key} for deletion test")
30+
31+
# Verify the entity exists
32+
assert arkiv_client_http.arkiv.entity_exists(entity_key), (
33+
"Entity should exist after creation"
34+
)
35+
36+
# Delete the entity
37+
delete_tx_hash = arkiv_client_http.arkiv.delete_entity(entity_key)
38+
39+
label = "delete_entity"
40+
check_tx_hash(label, delete_tx_hash)
41+
logger.info(f"{label}: Deleted entity {entity_key}, tx_hash: {delete_tx_hash}")
42+
43+
# Verify the entity no longer exists
44+
assert not arkiv_client_http.arkiv.entity_exists(entity_key), (
45+
"Entity should not exist after deletion"
46+
)
47+
48+
logger.info(f"{label}: Entity deletion successful")
49+
50+
def test_delete_multiple_entities_sequentially(
51+
self, arkiv_client_http: Arkiv
52+
) -> None:
53+
"""Test deleting multiple entities one by one."""
54+
# Create multiple entities
55+
entity_keys = []
56+
for i in range(3):
57+
payload = f"Entity {i} for sequential deletion".encode()
58+
annotations: Annotations = Annotations({"index": i, "batch": "sequential"})
59+
entity_key, _ = arkiv_client_http.arkiv.create_entity(
60+
payload=payload, annotations=annotations, btl=150
61+
)
62+
entity_keys.append(entity_key)
63+
64+
logger.info(f"Created {len(entity_keys)} entities for sequential deletion")
65+
66+
# Verify all entities exist
67+
for entity_key in entity_keys:
68+
assert arkiv_client_http.arkiv.entity_exists(entity_key), (
69+
f"Entity {entity_key} should exist before deletion"
70+
)
71+
72+
# Delete entities one by one
73+
for i, entity_key in enumerate(entity_keys):
74+
delete_tx_hash = arkiv_client_http.arkiv.delete_entity(entity_key)
75+
check_tx_hash(f"delete_entity_{i}", delete_tx_hash)
76+
logger.info(f"Deleted entity {i + 1}/{len(entity_keys)}: {entity_key}")
77+
78+
# Verify this entity is deleted
79+
assert not arkiv_client_http.arkiv.entity_exists(entity_key), (
80+
f"Entity {entity_key} should not exist after deletion"
81+
)
82+
83+
# Verify all entities are gone
84+
for entity_key in entity_keys:
85+
assert not arkiv_client_http.arkiv.entity_exists(entity_key), (
86+
f"Entity {entity_key} should still be deleted"
87+
)
88+
89+
logger.info("Sequential deletion of multiple entities successful")
90+
91+
def test_delete_entity_execute_bulk(self, arkiv_client_http: Arkiv) -> None:
92+
"""Test deleting entities that were created in bulk."""
93+
# Create entities in bulk
94+
create_ops = [
95+
CreateOp(
96+
payload=f"Bulk entity {i}".encode(),
97+
annotations=Annotations({"batch": "bulk", "index": i}),
98+
btl=100,
99+
)
100+
for i in range(3)
101+
]
102+
103+
entity_keys, _ = arkiv_client_http.arkiv.create_entities(create_ops)
104+
105+
logger.info(f"Created {len(entity_keys)} entities in bulk")
106+
107+
# Verify all exist
108+
for entity_key in entity_keys:
109+
assert arkiv_client_http.arkiv.entity_exists(entity_key), (
110+
"Bulk-created entity should exist"
111+
)
112+
113+
# Bulk delete
114+
# Wrap in Operations container and execute
115+
delete_ops = [DeleteOp(entity_key=key) for key in entity_keys]
116+
operations = Operations(deletes=delete_ops)
117+
receipt = arkiv_client_http.arkiv.execute(operations)
118+
119+
# Check transaction hash of bulk delete
120+
check_tx_hash("delete_bulk_entity", receipt.tx_hash)
121+
122+
# Verify all deletes succeeded
123+
if len(receipt.deletes) != len(delete_ops):
124+
raise RuntimeError(
125+
f"Expected {len(delete_ops)} deletes in receipt, got {len(receipt.deletes)}"
126+
)
127+
128+
# Verify all are deleted
129+
for entity_key in entity_keys:
130+
assert not arkiv_client_http.arkiv.entity_exists(entity_key), (
131+
"Bulk-created entity should be deleted"
132+
)
133+
134+
logger.info("Deletion of bulk-created entities successful")
135+
136+
def test_delete_nonexistent_entity_behavior(self, arkiv_client_http: Arkiv) -> None:
137+
"""Test that deleting a non-existent entity raises an exception."""
138+
from eth_typing import HexStr
139+
from web3.exceptions import Web3RPCError
140+
141+
from arkiv.types import EntityKey
142+
143+
# Create a fake entity key (should not exist)
144+
fake_entity_key = EntityKey(
145+
HexStr("0x0000000000000000000000000000000000000000000000000000000000000001")
146+
)
147+
148+
# Verify it doesn't exist
149+
assert not arkiv_client_http.arkiv.entity_exists(fake_entity_key), (
150+
"Fake entity should not exist"
151+
)
152+
153+
# Attempt to delete should raise a Web3RPCError
154+
with pytest.raises(Web3RPCError) as exc_info:
155+
logger.info(
156+
f"Attempting to delete non-existent entity {fake_entity_key} -> {exc_info}"
157+
)
158+
arkiv_client_http.arkiv.delete_entity(fake_entity_key)
159+
160+
# Verify the error message indicates entity not found
161+
error_message = str(exc_info.value)
162+
assert "entity" in error_message.lower(), "Error message should mention entity"
163+
assert "not found" in error_message.lower(), (
164+
"Error message should indicate entity not found"
165+
)
166+
167+
logger.info(
168+
f"Delete of non-existent entity correctly raised {type(exc_info.value).__name__}"
169+
)
170+
171+
def test_delete_entity_twice(self, arkiv_client_http: Arkiv) -> None:
172+
"""Test that deleting the same entity twice raises an exception."""
173+
from web3.exceptions import Web3RPCError
174+
175+
# Create an entity
176+
entity_key, _ = arkiv_client_http.arkiv.create_entity(
177+
payload=b"Entity to delete twice", btl=100
178+
)
179+
180+
# First deletion
181+
delete_tx_hash_1 = arkiv_client_http.arkiv.delete_entity(entity_key)
182+
check_tx_hash("first_delete", delete_tx_hash_1)
183+
184+
# Verify it's deleted
185+
assert not arkiv_client_http.arkiv.entity_exists(entity_key), (
186+
"Entity should be deleted after first deletion"
187+
)
188+
189+
# Second deletion attempt should raise a Web3RPCError
190+
with pytest.raises(Web3RPCError) as exc_info:
191+
arkiv_client_http.arkiv.delete_entity(entity_key)
192+
193+
# Verify the error message indicates entity not found
194+
error_message = str(exc_info.value)
195+
assert "entity" in error_message.lower(), "Error message should mention entity"
196+
assert "not found" in error_message.lower(), (
197+
"Error message should indicate entity not found"
198+
)
199+
200+
logger.info(
201+
f"Second delete of same entity correctly raised {type(exc_info.value).__name__}"
202+
)

tests/utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,27 @@
33
from pathlib import Path
44

55
from arkiv.account import NamedAccount
6+
from arkiv.types import TxHash
67

78
WALLET_FILE_ENV_PREFIX = "WALLET_FILE"
89
WALLET_PASSWORD_ENV_PREFIX = "WALLET_PASSWORD"
910

1011
logger = logging.getLogger(__name__)
1112

1213

14+
def check_tx_hash(label: str, tx_hash: TxHash) -> None:
15+
"""Check transaction hash validity."""
16+
logger.info(f"{label}: Checking transaction hash {tx_hash}")
17+
assert tx_hash is not None, f"{label}: Transaction hash should not be None"
18+
assert isinstance(tx_hash, str), (
19+
f"{label}: Transaction hash should be a string (TxHash)"
20+
)
21+
assert len(tx_hash) == 66, (
22+
f"{label}: Transaction hash should be 66 characters long (0x + 64 hex)"
23+
)
24+
assert tx_hash.startswith("0x"), f"{label}: Transaction hash should start with 0x"
25+
26+
1327
def create_account(index: int, name: str) -> NamedAccount:
1428
"""Create a named account from env vars or generate a new one."""
1529
wallet_file = os.getenv(f"{WALLET_FILE_ENV_PREFIX}_{index}")

0 commit comments

Comments
 (0)