Skip to content

Commit 46d72e9

Browse files
committed
feat(tck): implement appendFile JSON-RPC method
Adds the appendFile handler wrapping FileAppendTransaction, following the same pattern as createFile and submitTopicMessage. Chunking stays delegated to the SDK's ChunkedTransaction; only chunkSize/maxChunks are wired through. Closes hiero-ledger#2493 Signed-off-by: tannaya7 <234580934+tannaya7@users.noreply.github.com>
1 parent 9c28077 commit 46d72e9

3 files changed

Lines changed: 202 additions & 2 deletions

File tree

tck/handlers/file.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from hiero_sdk_python.file.file_append_transaction import FileAppendTransaction
34
from hiero_sdk_python.file.file_contents_query import FileContentsQuery
45
from hiero_sdk_python.file.file_create_transaction import FileCreateTransaction
56
from hiero_sdk_python.file.file_id import FileId
@@ -11,7 +12,8 @@
1112
from hiero_sdk_python.transaction.transaction_receipt import TransactionReceipt
1213
from tck.errors import JsonRpcError
1314
from tck.handlers.registry import rpc_method
14-
from tck.param.file import CreateFileParams, GetFileContentsParams, GetFileInfoParams
15+
from tck.param.file import AppendFileParams, CreateFileParams, GetFileContentsParams, GetFileInfoParams
16+
from tck.response.base import StatusOnlyResponse
1517
from tck.response.file import CreateFileResponse, GetFileContentsResponse, GetFileInfoResponse
1618
from tck.util.client_utils import get_client
1719
from tck.util.constants import DEFAULT_GRPC_TIMEOUT
@@ -60,6 +62,41 @@ def create_file(params: CreateFileParams) -> CreateFileResponse:
6062
return CreateFileResponse(file_id, ResponseCode(receipt.status).name)
6163

6264

65+
def _build_append_file_transaction(params: AppendFileParams) -> FileAppendTransaction:
66+
transaction = FileAppendTransaction().set_grpc_deadline(DEFAULT_GRPC_TIMEOUT)
67+
68+
if params.fileId is not None:
69+
transaction.set_file_id(FileId.from_string(params.fileId))
70+
71+
# chunk count is derived from contents/chunk_size, so set those before max_chunks
72+
if params.contents is not None:
73+
transaction.set_contents(params.contents)
74+
75+
if params.chunkSize is not None:
76+
transaction.set_chunk_size(params.chunkSize)
77+
78+
if params.maxChunks is not None:
79+
transaction.set_max_chunks(params.maxChunks)
80+
81+
return transaction
82+
83+
84+
@rpc_method("appendFile")
85+
def append_file(params: AppendFileParams) -> StatusOnlyResponse:
86+
"""Append contents to a file."""
87+
client = get_client(params.sessionId)
88+
89+
transaction = _build_append_file_transaction(params)
90+
91+
if params.commonTransactionParams is not None:
92+
params.commonTransactionParams.apply_common_params(transaction, client)
93+
94+
response = transaction.execute(client, wait_for_receipt=False)
95+
receipt: TransactionReceipt = response.get_receipt(client, validate_status=True)
96+
97+
return StatusOnlyResponse(ResponseCode(receipt.status).name)
98+
99+
63100
@rpc_method("getFileContents")
64101
def get_file_contents(params: GetFileContentsParams) -> GetFileContentsResponse:
65102
client = get_client(params.sessionId)

tck/param/file.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from dataclasses import dataclass
44

55
from tck.param.base import BaseParams, BaseTransactionParams
6-
from tck.util.param_utils import parse_common_transaction_params, parse_session_id
6+
from tck.util.param_utils import parse_common_transaction_params, parse_session_id, to_int
77

88

99
@dataclass
@@ -35,6 +35,27 @@ def parse_json_params(cls, params: dict) -> CreateFileParams:
3535
)
3636

3737

38+
@dataclass
39+
class AppendFileParams(BaseTransactionParams):
40+
"""Parameters for appending contents to a file. Extends BaseTransactionParams to include common transaction parameters."""
41+
42+
fileId: str | None = None
43+
contents: str | None = None
44+
maxChunks: int | None = None
45+
chunkSize: int | None = None
46+
47+
@classmethod
48+
def parse_json_params(cls, params: dict) -> AppendFileParams:
49+
return cls(
50+
fileId=params.get("fileId"),
51+
contents=params.get("contents"),
52+
maxChunks=to_int(params.get("maxChunks")),
53+
chunkSize=to_int(params.get("chunkSize")),
54+
sessionId=parse_session_id(params),
55+
commonTransactionParams=parse_common_transaction_params(params),
56+
)
57+
58+
3859
@dataclass
3960
class GetFileContentsParams(BaseParams):
4061
"""Parameters for getting a file's contents."""

tests/tck/file_test.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Test cases for the appendFile TCK handler and its parameter parsing."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import MagicMock, patch
6+
7+
import pytest
8+
9+
from hiero_sdk_python.exceptions import PrecheckError, ReceiptStatusError
10+
from hiero_sdk_python.response_code import ResponseCode
11+
from tck.handlers import registry
12+
from tck.handlers.file import append_file
13+
from tck.param.file import AppendFileParams
14+
from tck.response.base import StatusOnlyResponse
15+
16+
17+
pytestmark = pytest.mark.unit
18+
19+
20+
def _mock_transaction():
21+
"""A MagicMock standing in for FileAppendTransaction's fluent setter chain."""
22+
tx = MagicMock()
23+
tx.set_grpc_deadline.return_value = tx
24+
tx.set_file_id.return_value = tx
25+
tx.set_contents.return_value = tx
26+
tx.set_chunk_size.return_value = tx
27+
tx.set_max_chunks.return_value = tx
28+
29+
receipt = MagicMock(status=ResponseCode.SUCCESS)
30+
tx.execute.return_value.get_receipt.return_value = receipt
31+
return tx
32+
33+
34+
def test_append_file_is_registered():
35+
handler = registry.get_handler("appendFile")
36+
assert handler is not None and callable(handler)
37+
38+
39+
def test_parse_json_params():
40+
"""Only contents is required; maxChunks/chunkSize go through to_int."""
41+
params = AppendFileParams.parse_json_params(
42+
{
43+
"sessionId": "session-1",
44+
"fileId": "0.0.100",
45+
"contents": "hello world",
46+
"maxChunks": "5",
47+
"chunkSize": "1024",
48+
}
49+
)
50+
51+
assert params.fileId == "0.0.100"
52+
assert params.contents == "hello world"
53+
assert params.maxChunks == 5
54+
assert params.chunkSize == 1024
55+
56+
minimal = AppendFileParams.parse_json_params({"sessionId": "session-1", "contents": "hello"})
57+
assert minimal.fileId is None
58+
assert minimal.maxChunks is None
59+
assert minimal.chunkSize is None
60+
61+
62+
def test_append_file_wires_setters_and_returns_status():
63+
"""contents/chunkSize must land before maxChunks, since chunk count derives from them."""
64+
params = AppendFileParams(
65+
sessionId="session-1", fileId="0.0.100", contents="hello world", maxChunks=5, chunkSize=1024
66+
)
67+
tx = _mock_transaction()
68+
69+
with (
70+
patch("tck.handlers.file.get_client", return_value=MagicMock()),
71+
patch("tck.handlers.file.FileAppendTransaction", return_value=tx),
72+
):
73+
result = append_file(params)
74+
75+
assert isinstance(result, StatusOnlyResponse)
76+
assert result.status == "SUCCESS"
77+
78+
tx.set_file_id.assert_called_once()
79+
tx.set_contents.assert_called_once_with("hello world")
80+
tx.set_chunk_size.assert_called_once_with(1024)
81+
tx.set_max_chunks.assert_called_once_with(5)
82+
83+
setter_order = [call[0] for call in tx.method_calls if call[0].startswith("set_")]
84+
assert setter_order.index("set_contents") < setter_order.index("set_max_chunks")
85+
assert setter_order.index("set_chunk_size") < setter_order.index("set_max_chunks")
86+
87+
88+
def test_append_file_applies_common_transaction_params():
89+
"""commonTransactionParams.signers has to reach freeze/sign via apply_common_params."""
90+
common_params = MagicMock()
91+
params = AppendFileParams(sessionId="session-1", contents="hello", commonTransactionParams=common_params)
92+
tx = _mock_transaction()
93+
client = MagicMock()
94+
95+
with (
96+
patch("tck.handlers.file.get_client", return_value=client),
97+
patch("tck.handlers.file.FileAppendTransaction", return_value=tx),
98+
):
99+
append_file(params)
100+
101+
common_params.apply_common_params.assert_called_once_with(tx, client)
102+
103+
104+
def test_append_file_propagates_receipt_failure():
105+
"""A bad receipt status (e.g. non-existent file -> INVALID_FILE_ID) should raise, not be swallowed."""
106+
params = AppendFileParams(sessionId="session-1", fileId="0.0.999999", contents="hello")
107+
tx = _mock_transaction()
108+
tx.execute.return_value.get_receipt.side_effect = ReceiptStatusError(
109+
status=ResponseCode.INVALID_FILE_ID,
110+
transaction_id=None,
111+
transaction_receipt=MagicMock(),
112+
message="INVALID_FILE_ID",
113+
)
114+
115+
with (
116+
patch("tck.handlers.file.get_client", return_value=MagicMock()),
117+
patch("tck.handlers.file.FileAppendTransaction", return_value=tx),
118+
pytest.raises(ReceiptStatusError),
119+
):
120+
append_file(params)
121+
122+
123+
def test_append_file_propagates_later_chunk_failure():
124+
"""A later chunk failing at submission must surface, not get masked by the first chunk's SUCCESS."""
125+
params = AppendFileParams(sessionId="session-1", fileId="0.0.100", contents="x" * 10_000)
126+
tx = _mock_transaction()
127+
tx.execute.side_effect = PrecheckError(status=1, transaction_id="0.0.1@1.1", message="later chunk failed")
128+
129+
with (
130+
patch("tck.handlers.file.get_client", return_value=MagicMock()),
131+
patch("tck.handlers.file.FileAppendTransaction", return_value=tx),
132+
pytest.raises(PrecheckError),
133+
):
134+
append_file(params)
135+
136+
137+
def test_append_file_invalid_file_id_raises():
138+
"""A malformed fileId should fail via FileId.from_string before hitting the network."""
139+
params = AppendFileParams(sessionId="session-1", fileId="not-a-file-id", contents="hello")
140+
141+
with patch("tck.handlers.file.get_client", return_value=MagicMock()), pytest.raises(ValueError):
142+
append_file(params)

0 commit comments

Comments
 (0)