Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
f694268
add tokenInfoQuery functionality
aceppaluni May 22, 2025
d5310c8
refactor: update TokenInfoQuery implementation
Dosik13 May 27, 2025
76b0d85
test: add unit tests
Dosik13 May 27, 2025
256e807
test: add integration tests
Dosik13 May 27, 2025
32443f1
docs: add examples for both non-fungible/fungible tokens
Dosik13 May 27, 2025
3c3a6ea
docs: update README in examples
Dosik13 May 27, 2025
1a453ee
chore: add TokenInfoQuery to __init__.py
Dosik13 May 27, 2025
1c6ac60
test: reduce test_token_info_query_execute function length
Dosik13 May 28, 2025
df1069c
test: fix incorrect integration test function names
Dosik13 May 28, 2025
67c9eb7
docs: change network from solo to testnet and some minor changes in e…
Dosik13 May 28, 2025
d7d6c18
test: address PR comments for integration/unit tests
Dosik13 May 30, 2025
79c1f10
docs: update examples for TokenInfoQuery to address PR comments
Dosik13 May 30, 2025
a4372ad
Merge branch 'hiero-ledger:main' into main
aceppaluni Jun 2, 2025
17ace16
Merge branch 'hiero-ledger:main' into main
aceppaluni Jun 4, 2025
090b2bb
Merge branch 'hiero-ledger:main' into main
aceppaluni Jun 16, 2025
854c0ca
Merge branch 'hiero-ledger:main' into main
aceppaluni Aug 6, 2025
168d706
Merge branch 'hiero-ledger:main' into main
aceppaluni Aug 7, 2025
143b727
Merge branch 'hiero-ledger:main' into main
aceppaluni Aug 15, 2025
ad6df88
Merge branch 'hiero-ledger:main' into main
aceppaluni Aug 20, 2025
fdcc0cb
Merge branch 'hiero-ledger:main' into main
aceppaluni Sep 3, 2025
b9ad245
Merge branch 'hiero-ledger:main' into main
aceppaluni Sep 8, 2025
7f4ee47
Merge branch 'hiero-ledger:main' into main
aceppaluni Oct 14, 2025
e17c504
Merge branch 'hiero-ledger:main' into main
aceppaluni Oct 19, 2025
22ba822
Merge branch 'hiero-ledger:main' into main
aceppaluni Oct 20, 2025
2741bde
Merge branch 'hiero-ledger:main' into main
aceppaluni Oct 21, 2025
9621065
Merge branch 'hiero-ledger:main' into main
aceppaluni Oct 22, 2025
9469f23
Merge branch 'hiero-ledger:main' into main
aceppaluni Oct 23, 2025
cfd8e1c
Merge branch 'hiero-ledger:main' into main
aceppaluni Oct 24, 2025
1cb2132
Merge branch 'hiero-ledger:main' into main
aceppaluni Oct 27, 2025
9328d1f
Merge branch 'hiero-ledger:main' into main
aceppaluni Oct 28, 2025
e594364
Merge branch 'hiero-ledger:main' into main
aceppaluni Oct 28, 2025
a5267b8
Merge branch 'hiero-ledger:main' into main
aceppaluni Oct 29, 2025
13b3573
Merge branch 'hiero-ledger:main' into main
aceppaluni Oct 30, 2025
177248e
Merge branch 'hiero-ledger:main' into main
aceppaluni Nov 4, 2025
7744971
Merge branch 'hiero-ledger:main' into main
aceppaluni Nov 9, 2025
277bf9d
Merge branch 'hiero-ledger:main' into main
aceppaluni Nov 11, 2025
1f4f5cf
Merge branch 'hiero-ledger:main' into main
aceppaluni Nov 17, 2025
91d86fb
Merge branch 'hiero-ledger:main' into main
aceppaluni Nov 19, 2025
fce4f9f
Merge branch 'hiero-ledger:main' into main
aceppaluni Nov 19, 2025
333f755
Merge branch 'hiero-ledger:main' into main
aceppaluni Nov 19, 2025
50f258e
Add set_node_account_id method
aceppaluni Sep 8, 2025
94c89d3
fix: add missing List import and update changelog
aceppaluni Oct 28, 2025
e83e701
style: add spacing in set_node_account_id method
aceppaluni Oct 28, 2025
cc7d9a8
feat: support selecting node account ID for execution flow
aceppaluni Oct 29, 2025
dac9cd2
Fix node selection to use _select_node instead of _get_node
aceppaluni Oct 30, 2025
13a542a
feat: add Network._get_node() and update executable to support node s…
aceppaluni Nov 4, 2025
7ffc7e4
feat: move node account ID selection to _Executable and update execut…
aceppaluni Nov 10, 2025
f521241
Fix imports for List and AccountId; update executable; sync with main
aceppaluni Nov 17, 2025
1062097
aligning with javascript sdk
nadineloepfe Nov 21, 2025
40bb65d
rebasing
nadineloepfe Nov 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,14 @@ jobs:
- name: Fail workflow if any tests failed
shell: bash
run: |
if [ "${{ steps.integration.outputs.integration_failed }}" != "0" ] || [ "${{ steps.unit.outputs.unit_failed }}" != "0" ]; then
integration_failed="${{ steps.integration.outputs.integration_failed }}"
unit_failed="${{ steps.unit.outputs.unit_failed }}"

# Default to 0 if empty
integration_failed="${integration_failed:-0}"
unit_failed="${unit_failed:-0}"

if [ "$integration_failed" != "0" ] || [ "$unit_failed" != "0" ]; then
echo "❌ Some tests failed. Failing workflow."
exit 1
else
Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
- Add comprehensive documentation for `ReceiptStatusError` in `docs/sdk_developers/training/receipt_status_error.md`
- Add practical example `examples/errors/receipt_status_error.py` demonstrating transaction error handling
- Document error handling patterns and best practices for transaction receipts

- Add detail to `token_airdrop.py` and `token_airdrop_cancel.py`
- Add workflow: github bot to respond to unverified PR commits (#750)
- Add workflow: bot workflow which notifies developers of workflow failures in their pull requests.
Expand Down Expand Up @@ -84,6 +83,7 @@ This changelog is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.
- Added expiration_time, auto_renew_period, auto_renew_account, fee_schedule_key, kyc_key in `TokenCreateTransaction`, `TokenUpdateTransaction` classes
- Added comprehensive Google-style docstrings to the `CustomFee` class and its methods in `custom_fee.py`.
- docs: Add `docs/sdk_developers/project_structure.md` to explain repository layout and import paths.
- Support selecting specific node account ID(s) for queries and transactions and added `Network._get_node()` with updated execution flow (#362)

### Changed
- chore: renamed examples to match src where possible
Expand Down
15 changes: 15 additions & 0 deletions src/hiero_sdk_python/client/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,21 @@ def _select_node(self) -> _Node:
self._node_index = (self._node_index + 1) % len(self.nodes)
self.current_node = self.nodes[self._node_index]
return self.current_node

def _get_node(self, account_id: AccountId) -> Optional[_Node]:
"""
Get a node matching the given account ID.

Args:
account_id (AccountId): The account ID of the node to locate.

Returns:
Optional[_Node]: The matching node, or None if not found.
"""
for node in self.nodes:
if node._account_id == account_id:
return node
return None

def get_mirror_address(self) -> str:
"""
Expand Down
57 changes: 51 additions & 6 deletions src/hiero_sdk_python/executable.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from os import error
import time
from typing import Callable, Optional, Any, TYPE_CHECKING
from typing import Callable, Optional, Any, TYPE_CHECKING, List
import grpc
from abc import ABC, abstractmethod
from enum import IntEnum

from hiero_sdk_python.channels import _Channel
from hiero_sdk_python.exceptions import MaxAttemptsError
from hiero_sdk_python.account.account_id import AccountId
if TYPE_CHECKING:
from hiero_sdk_python.client.client import Client

Expand Down Expand Up @@ -75,6 +76,33 @@ def __init__(self):
self._grpc_deadline = DEFAULT_GRPC_DEADLINE
self.node_account_id = None

self.node_account_ids: List[AccountId] = []
self._used_node_account_id: Optional[AccountId] = None
self._node_account_ids_index: int = 0

def set_node_account_ids(self, node_account_ids: List[AccountId]):
"""Select node account IDs for sending the request."""
self.node_account_ids = node_account_ids
return self

def set_node_account_id(self, node_account_id: AccountId):
"""Convenience wrapper to set a single node account ID."""
return self.set_node_account_ids([node_account_id])

def _select_node_account_id(self) -> Optional[AccountId]:
"""Pick the current node from the list if available, otherwise None."""
if self.node_account_ids:
# Use modulo to cycle through the list
selected = self.node_account_ids[self._node_account_ids_index % len(self.node_account_ids)]
self._used_node_account_id = selected
return selected
return None

def _advance_node_index(self):
"""Advance to the next node in the list."""
if self.node_account_ids:
self._node_account_ids_index += 1

@abstractmethod
def _should_retry(self, response) -> _ExecutionState:
"""
Expand Down Expand Up @@ -176,10 +204,20 @@ def _execute(self, client: "Client"):
if attempt > 0 and current_backoff < self._max_backoff:
current_backoff *= 2

# Set the node account id to the client's node account id
node = client.network.current_node
# Select preferred node if provided, fallback to client's default
selected = self._select_node_account_id()

if selected is not None:
node = client.network._get_node(selected)
else:
node = client.network.current_node

#Store for logging and receipts
self.node_account_id = node._account_id


# Advance to next node for the next attempt (if using explicit node list)
self._advance_node_index()

# Create a channel wrapper from the client's channel
channel = node._get_channel()

Expand Down Expand Up @@ -210,6 +248,10 @@ def _execute(self, client: "Client"):
case _ExecutionState.RETRY:
# If we should retry, wait for the backoff period and try again
err_persistant = status_error
# If not using explicit node list, switch to next node for retry
if not self.node_account_ids:
node = client.network._select_node()
logger.trace("Switched to a different node for retry", "error", err_persistant, "from node", self.node_account_id, "to node", node._account_id)
_delay_for_attempt(self._get_request_id(), current_backoff, attempt, logger, err_persistant)
continue
case _ExecutionState.EXPIRED:
Expand All @@ -223,8 +265,11 @@ def _execute(self, client: "Client"):
except grpc.RpcError as e:
# Save the error
err_persistant = f"Status: {e.code()}, Details: {e.details()}"
node = client.network._select_node()
logger.trace("Switched to a different node for the next attempt", "error", err_persistant, "from node", self.node_account_id, "to node", node._account_id)
# If not using explicit node list, switch to next node for retry
if not self.node_account_ids:
node = client.network._select_node()
logger.trace("Switched to a different node for the next attempt", "error", err_persistant, "from node", self.node_account_id, "to node", node._account_id)
_delay_for_attempt(self._get_request_id(), current_backoff, attempt, logger, err_persistant)
continue

logger.error("Exceeded maximum attempts for request", "requestId", self._get_request_id(), "last exception being", err_persistant)
Expand Down
6 changes: 1 addition & 5 deletions src/hiero_sdk_python/query/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ def __init__(self) -> None:
super().__init__()

self.timestamp: int = int(time.time())
self.node_account_ids: List[AccountId] = []
self.operator: Optional[Operator] = None
self.node_index: int = 0
self.payment_amount: Optional[Hbar] = None
Expand Down Expand Up @@ -106,11 +105,7 @@ def _before_execute(self, client: Client) -> None:
Args:
client: The client instance to use for execution
"""
if not self.node_account_ids:
self.node_account_ids = client.get_node_account_ids()

self.operator = self.operator or client.operator
self.node_account_ids = list(set(self.node_account_ids))

# If no payment amount was specified and payment is required for this query,
# get the cost from the network and set it as the payment amount
Expand Down Expand Up @@ -379,3 +374,4 @@ def _is_payment_required(self) -> bool:
bool: True if payment is required, False otherwise
"""
return True

2 changes: 1 addition & 1 deletion src/hiero_sdk_python/transaction/transaction.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import hashlib
from typing import Optional
from typing import List, Optional

from typing import TYPE_CHECKING

Expand Down
25 changes: 13 additions & 12 deletions tests/unit/test_executable.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def test_retry_success_before_max_attempts():
# First server gives 2 BUSY responses then OK on the 3rd try
response_sequences = [[busy_response, busy_response, ok_response, receipt_response]]

with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
# Configure client to allow 3 attempts - should succeed on the last try
client.max_attempts = 3

Expand All @@ -70,7 +70,7 @@ def test_retry_failure_after_max_attempts():

response_sequences = [[busy_response, busy_response]]

with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
client.max_attempts = 2

transaction = (
Expand Down Expand Up @@ -112,7 +112,7 @@ def test_node_switching_after_single_grpc_error():
[error],
]

with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
transaction = (
AccountCreateTransaction()
.set_key(PrivateKey.generate().public_key())
Expand Down Expand Up @@ -149,7 +149,7 @@ def test_node_switching_after_multiple_grpc_errors():
[ok_response, receipt_response],
]

with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
transaction = (
AccountCreateTransaction()
.set_key(PrivateKey.generate().public_key())
Expand Down Expand Up @@ -185,7 +185,7 @@ def test_transaction_with_expired_error_not_retried():
[error_response]
]

with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
transaction = (
AccountCreateTransaction()
.set_key(PrivateKey.generate().public_key())
Expand Down Expand Up @@ -216,7 +216,7 @@ def test_transaction_with_fatal_error_not_retried():
[error_response]
]

with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
transaction = (
AccountCreateTransaction()
.set_key(PrivateKey.generate().public_key())
Expand Down Expand Up @@ -248,7 +248,7 @@ def test_exponential_backoff_retry():
response_sequences = [[busy_response, busy_response, busy_response, ok_response, receipt_response]]

# Use a mock for time.sleep to capture the delay values
with mock_hedera_servers(response_sequences) as client, patch('time.sleep') as mock_sleep:
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep') as mock_sleep:
client.max_attempts = 5

transaction = (
Expand Down Expand Up @@ -288,7 +288,7 @@ def test_retriable_error_does_not_switch_node():
)
)
response_sequences = [[busy_response, ok_response, receipt_response]]
with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
transaction = (
AccountCreateTransaction()
.set_key(PrivateKey.generate().public_key())
Expand Down Expand Up @@ -333,7 +333,7 @@ def test_topic_create_transaction_retry_on_busy():
[busy_response, ok_response, receipt_response],
]

with mock_hedera_servers(response_sequences) as client, patch('time.sleep') as mock_sleep:
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep') as mock_sleep:
client.max_attempts = 3

tx = (
Expand Down Expand Up @@ -367,7 +367,7 @@ def test_topic_create_transaction_fails_on_nonretriable_error():
[error_response],
]

with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
tx = (
TopicCreateTransaction()
.set_memo("Test with error")
Expand Down Expand Up @@ -400,7 +400,7 @@ def test_transaction_node_switching_body_bytes():
[ok_response, receipt_response],
]

with mock_hedera_servers(response_sequences) as client, patch('time.sleep'):
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep'):
# We set the current node to 0
client.network._node_index = 0
client.network.current_node = client.network.nodes[0]
Expand Down Expand Up @@ -467,8 +467,9 @@ def test_query_retry_on_busy():
[ok_response],
]

with mock_hedera_servers(response_sequences) as client, patch('time.sleep') as mock_sleep:
with mock_hedera_servers(response_sequences) as client, patch('hiero_sdk_python.executable.time.sleep') as mock_sleep:
# We set the current node to the first node so we are sure it will return BUSY response
client.network._node_index = 0
client.network.current_node = client.network.nodes[0]

query = CryptoGetAccountBalanceQuery()
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/test_query_nodes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import pytest
from hiero_sdk_python.query.query import Query
from hiero_sdk_python.account.account_id import AccountId

def test_set_single_node_account_id():
q = Query()
node = AccountId(0, 0, 3)

q.set_node_account_id(node)

assert q.node_account_ids == [node]
assert q._used_node_account_id is None # not selected until execution

def test_set_multiple_node_account_ids():
q = Query()
nodes = [AccountId(0, 0, 3), AccountId(0, 0, 4)]

q.set_node_account_ids(nodes)

assert q.node_account_ids == nodes
assert q._used_node_account_id is None

def test_select_node_account_id():
q = Query()
nodes = [AccountId(0, 0, 3), AccountId(0, 0, 4)]
q.set_node_account_ids(nodes)

selected = q._select_node_account_id()

assert selected == nodes[0]
assert q._used_node_account_id == nodes[0]
52 changes: 52 additions & 0 deletions tests/unit/test_transaction_nodes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import pytest
from hiero_sdk_python.transaction.transaction import Transaction
from hiero_sdk_python.account.account_id import AccountId


class DummyTransaction(Transaction):
"""
Minimal subclass of Transaction for testing.
Transaction is abstract (requires build methods), so we stub them out.
"""
def __init__(self):
super().__init__()

def build_base_transaction_body(self):
return None # stub

def _make_request(self):
return None # stub

def _get_method(self):
return None # stub


def test_set_single_node_account_id():
txn = DummyTransaction()
node = AccountId(0, 0, 3)

txn.set_node_account_id(node)

assert txn.node_account_ids == [node]
assert txn._used_node_account_id is None


def test_set_multiple_node_account_ids():
txn = DummyTransaction()
nodes = [AccountId(0, 0, 3), AccountId(0, 0, 4)]

txn.set_node_account_ids(nodes)

assert txn.node_account_ids == nodes
assert txn._used_node_account_id is None


def test_select_node_account_id():
txn = DummyTransaction()
nodes = [AccountId(0, 0, 3), AccountId(0, 0, 4)]
txn.set_node_account_ids(nodes)

selected = txn._select_node_account_id()

assert selected == nodes[0]
assert txn._used_node_account_id == nodes[0]
Loading