Skip to content

Commit bd9a02e

Browse files
authored
feat: Add for_network() method to configure a custom network (#2182)
Signed-off-by: Manish Dait <daitmanish88@gmail.com>
1 parent 8bca5b8 commit bd9a02e

5 files changed

Lines changed: 319 additions & 40 deletions

File tree

src/hiero_sdk_python/client/client.py

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@
1212
from dotenv import load_dotenv
1313

1414
from hiero_sdk_python.account.account_id import AccountId
15-
from hiero_sdk_python.channels import _UserAgentInterceptor
1615
from hiero_sdk_python.crypto.private_key import PrivateKey
1716
from hiero_sdk_python.hapi.mirror import (
1817
consensus_service_pb2_grpc as mirror_consensus_grpc,
1918
)
2019
from hiero_sdk_python.hbar import Hbar
2120
from hiero_sdk_python.logger.logger import Logger, LogLevel
21+
from hiero_sdk_python.node import _Node
2222
from hiero_sdk_python.transaction.transaction_id import TransactionId
2323

2424
from .network import Network
@@ -57,9 +57,6 @@ def __init__(self, network: Network = None) -> None:
5757

5858
self.network: Network = network
5959

60-
self.mirror_channel: grpc.Channel = None
61-
self.mirror_stub: mirror_consensus_grpc.ConsensusServiceStub = None
62-
6360
self.max_attempts: int = 10
6461
self.default_max_query_payment: Hbar = DEFAULT_MAX_QUERY_PAYMENT
6562

@@ -69,10 +66,17 @@ def __init__(self, network: Network = None) -> None:
6966
self._grpc_deadline: float = DEFAULT_GRPC_DEADLINE
7067
self._request_timeout: float = DEFAULT_REQUEST_TIMEOUT
7168

72-
self._init_mirror_stub()
73-
7469
self.logger: Logger = Logger(LogLevel.from_env(), "hiero_sdk_python")
7570

71+
@property
72+
def mirror_stub(self) -> mirror_consensus_grpc.ConsensusServiceStub:
73+
return self.network.get_mirror_stub()
74+
75+
@property
76+
def mirror_channel(self) -> grpc.Channel:
77+
self.network.get_mirror_stub()
78+
return self.network._mirror_channel
79+
7680
@classmethod
7781
def from_env(cls, network: NetworkName | None = None) -> Client:
7882
"""
@@ -153,20 +157,32 @@ def for_previewnet(cls) -> Client:
153157
"""
154158
return cls(Network("previewnet"))
155159

156-
def _init_mirror_stub(self) -> None:
160+
@classmethod
161+
def for_network(cls, network_map: dict[str, AccountId], network_name: str | None = "localhost") -> Client:
157162
"""
158-
Connect to a mirror node for topic message subscriptions.
159-
Mirror nodes always use TLS (mandatory). We use self.network.get_mirror_address()
160-
for a configurable mirror address, which should use port 443 for HTTPS connections.
163+
Create a Client with a custom set of nodes and an optional network label.
164+
165+
Args:
166+
network_map (dict[str, AccountId]): A map where keys are "host:port" strings
167+
and values are the node's AccountId.
168+
network_name (str): A label for the network. Defaults to "localhost".
169+
170+
Returns:
171+
Client: A Client instance configured with the custom network.
161172
"""
162-
mirror_address = self.network.get_mirror_address()
163-
if mirror_address.endswith(":50212") or mirror_address.endswith(":443"):
164-
self.mirror_channel = grpc.secure_channel(mirror_address, grpc.ssl_channel_credentials())
165-
else:
166-
self.mirror_channel = grpc.insecure_channel(mirror_address)
173+
if not network_map:
174+
raise ValueError("network_map cannot be empty")
175+
176+
first_node = next(iter(network_map.values()))
177+
shard = first_node.shard
178+
realm = first_node.realm
167179

168-
self.mirror_channel = grpc.intercept_channel(self.mirror_channel, _UserAgentInterceptor())
169-
self.mirror_stub = mirror_consensus_grpc.ConsensusServiceStub(self.mirror_channel)
180+
for account_id in network_map.values():
181+
if shard != account_id.shard or realm != account_id.realm:
182+
raise ValueError("network is not valid, all nodes must be in the same shard and realm")
183+
184+
nodes = [_Node(account_id, address, None) for address, account_id in network_map.items()]
185+
return cls(Network(network=network_name, nodes=nodes))
170186

171187
def set_operator(self, account_id: AccountId, private_key: PrivateKey) -> None:
172188
"""Sets the operator credentials (account ID and private key)."""
@@ -203,17 +219,7 @@ def close(self) -> None:
203219
Closes any open gRPC channels and frees resources.
204220
Call this when you are done using the Client to ensure a clean shutdown.
205221
"""
206-
# Close mirror channel
207-
if self.mirror_channel is not None:
208-
self.mirror_channel.close()
209-
self.mirror_channel = None
210-
211-
self.mirror_stub = None
212-
213-
# Fix: Close all consensus node channels
214-
if self.network and self.network.nodes:
215-
for node in self.network.nodes:
216-
node._close()
222+
self.network._close()
217223

218224
def set_transport_security(self, enabled: bool) -> Client:
219225
"""

src/hiero_sdk_python/client/network.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
import time
77
from typing import Any
88

9+
import grpc
910
import requests
1011

1112
from hiero_sdk_python.account.account_id import AccountId
1213
from hiero_sdk_python.address_book.node_address import NodeAddress
14+
from hiero_sdk_python.hapi.mirror import consensus_service_pb2_grpc as mirror_consensus_grpc
1315
from hiero_sdk_python.node import _Node
1416

1517

@@ -73,7 +75,7 @@ class Network:
7375

7476
def __init__(
7577
self,
76-
network: str = "testnet",
78+
network: str | None = None,
7779
nodes: list[_Node] | None = None,
7880
mirror_address: str | None = None,
7981
ledger_id: bytes | None = None,
@@ -97,9 +99,11 @@ def __init__(
9799
Use Client.set_transport_security() and Client.set_verify_certificates() to customize.
98100
"""
99101
self.network: str = network or "testnet"
100-
self.mirror_address: str = mirror_address or self.MIRROR_ADDRESS_DEFAULT.get(network, "localhost:5600")
102+
self._mirror_address: str = mirror_address or self.MIRROR_ADDRESS_DEFAULT.get(self.network, "localhost:5600")
103+
self._mirror_channel: grpc.Channel | None = None
104+
self._mirror_stub: mirror_consensus_grpc.ConsensusServiceStub | None = None
101105

102-
self.ledger_id = ledger_id or self.LEDGER_ID.get(network, bytes.fromhex("03"))
106+
self.ledger_id = ledger_id or self.LEDGER_ID.get(self.network, bytes.fromhex("03"))
103107

104108
# Default TLS configuration: enabled for hosted networks, disabled for local/custom
105109
hosted_networks = ("mainnet", "testnet", "previewnet")
@@ -122,13 +126,32 @@ def __init__(
122126
self._node_index: int = secrets.randbelow(len(self._healthy_nodes))
123127
self.current_node: _Node = self._healthy_nodes[self._node_index]
124128

129+
@property
130+
def mirror_address(self) -> str:
131+
return self._mirror_address
132+
133+
@mirror_address.setter
134+
def mirror_address(self, value: str):
135+
"""Reset the connection when the address changes."""
136+
if not isinstance(value, str):
137+
raise TypeError(f"mirror_address must be a string, not {type(value).__name__}")
138+
139+
value = value.strip()
140+
if not value:
141+
raise ValueError("mirror_address cannot be empty or just whitespace")
142+
143+
if self._mirror_address != value:
144+
self._mirror_address = value
145+
self._close_mirror_node()
146+
125147
def _set_network_nodes(self, nodes: list[_Node] | None = None):
126148
"""Configure the consensus nodes used by this network."""
127149
final_nodes = self._resolve_nodes(nodes)
128150

129151
# Apply TLS configuration to all nodes
130152
for node in final_nodes:
131-
node._apply_transport_security(self._transport_security) # pylint: disable=protected-access
153+
if self._transport_security:
154+
node._apply_transport_security(self._transport_security) # pylint: disable=protected-access
132155
node._set_verify_certificates(self._verify_certificates) # pylint: disable=protected-access
133156
node._set_root_certificates(self._root_certificates) # pylint: disable=protected-access
134157

@@ -405,3 +428,33 @@ def _mark_node_healthy(self, node: _Node) -> None:
405428

406429
if node not in self._healthy_nodes:
407430
self._healthy_nodes.append(node)
431+
432+
def _close_mirror_node(self):
433+
"""Safely closes the mirror gRPC channel."""
434+
if self._mirror_channel is not None:
435+
self._mirror_channel.close()
436+
437+
self._mirror_channel = None
438+
self._mirror_stub = None
439+
440+
def _close(self):
441+
"""Safely closes the mirror gRPC channel and consensus node."""
442+
self._close_mirror_node()
443+
444+
if self.nodes:
445+
for node in self.nodes:
446+
node._close()
447+
448+
def get_mirror_stub(self) -> mirror_consensus_grpc.ConsensusServiceStub:
449+
"""Returns the mirror stub."""
450+
if self._mirror_stub is None:
451+
addr = self._mirror_address
452+
453+
if addr.endswith(":50212") or addr.endswith(":443"):
454+
self._mirror_channel = grpc.secure_channel(addr, grpc.ssl_channel_credentials())
455+
else:
456+
self._mirror_channel = grpc.insecure_channel(addr)
457+
458+
self._mirror_stub = mirror_consensus_grpc.ConsensusServiceStub(self._mirror_channel)
459+
460+
return self._mirror_stub

tck/handlers/sdk.py

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

3-
from hiero_sdk_python import AccountId, Client, Network, PrivateKey
4-
from hiero_sdk_python.node import _Node
3+
from hiero_sdk_python import AccountId, Client, PrivateKey
54
from tck.handlers.registry import rpc_method
65
from tck.param.base import BaseParams
76
from tck.param.sdk import SetupParams
@@ -15,11 +14,11 @@ def setup_handler(params: SetupParams) -> SetupResponse:
1514
operator_private_key = PrivateKey.from_string(params.operatorPrivateKey)
1615

1716
if params.nodeIp and params.nodeAccountId and params.mirrorNetworkIp:
18-
client = Client()
19-
client.network = Network(
20-
nodes=[_Node(AccountId.from_string(params.nodeAccountId), params.nodeIp, None)],
21-
mirror_address=params.mirrorNetworkIp,
22-
)
17+
nodes = {params.nodeIp: AccountId.from_string(params.nodeAccountId)}
18+
19+
client = Client.for_network(network_map=nodes)
20+
client.network.mirror_address = params.mirrorNetworkIp
21+
2322
client.set_operator(operator_account_id, operator_private_key)
2423

2524
client_type = "custom"

tests/unit/client_test.py

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010

1111
import pytest
1212

13-
from hiero_sdk_python import AccountId, Client, PrivateKey
13+
from hiero_sdk_python import AccountId, Client
1414
from hiero_sdk_python.client import client as client_module
15+
from hiero_sdk_python.client.network import Network
16+
from hiero_sdk_python.crypto.private_key import PrivateKey
1517
from hiero_sdk_python.hbar import Hbar
1618
from hiero_sdk_python.node import _Node
1719
from hiero_sdk_python.transaction.transaction_id import TransactionId
@@ -527,3 +529,78 @@ def test_get_node_account_ids_raises_when_no_nodes():
527529
client.get_node_account_ids()
528530

529531
client.close()
532+
533+
534+
def test_for_network_initializes_with_custom_map():
535+
"""Test for_network correctly maps strings to AccountIds and Nodes."""
536+
network_map = {"127.0.0.1:50211": AccountId(0, 0, 3), "127.0.0.1:50212": AccountId(0, 0, 4)}
537+
538+
client = Client.for_network(network_map)
539+
540+
assert isinstance(client.network, Network)
541+
assert len(client.network.nodes) == 2
542+
543+
node_accounts = [node._account_id.__str__() for node in client.network.nodes]
544+
assert "0.0.3" in node_accounts
545+
assert "0.0.4" in node_accounts
546+
547+
548+
@pytest.mark.parametrize("network", ["mainnet", "testnet", "previewnet"])
549+
def test_for_network_with_hosted_network_forces_tls(network):
550+
"""Test that if hosted-net the port 50211 is upgraded to 50212."""
551+
network_map = {"34.94.106.61:50211": AccountId(0, 0, 3)}
552+
client = Client.for_network(network_map, network_name=network)
553+
554+
node = client.network.nodes[0]
555+
assert str(node._address) == "34.94.106.61:50212"
556+
assert node._address._is_transport_security() is True
557+
assert client.network.is_transport_security() is True
558+
559+
560+
@pytest.mark.parametrize("network", ["local", "localhost", "solo", "custom"])
561+
def test_for_network_with_non_hosted_network_not_forces_tls(network):
562+
"""Test that if non hosted-net the port 50211 does not change."""
563+
network_map = {"127.0.0.1:50211": AccountId(0, 0, 3)}
564+
client = Client.for_network(network_map, network_name=network)
565+
566+
node = client.network.nodes[0]
567+
assert str(node._address) == "127.0.0.1:50211"
568+
assert node._address._is_transport_security() is False
569+
assert client.network.is_transport_security() is False
570+
571+
572+
@pytest.mark.parametrize("network", ["local", "localhost", "solo", "custom"])
573+
def test_for_network_with_non_hosted_network_not_downgrade_tls(network):
574+
"""Test that if non hosted-net the port 50212 does not change."""
575+
network_map = {"127.0.0.1:50212": AccountId(0, 0, 3)}
576+
client = Client.for_network(network_map, network_name=network)
577+
578+
node = client.network.nodes[0]
579+
assert str(node._address) == "127.0.0.1:50212"
580+
assert node._address._is_transport_security() is True
581+
assert client.network.is_transport_security() is False # Non hosted network.
582+
583+
584+
def test_for_network_with_empty_map_raises_error():
585+
"""Test that for_network raises ValueError when map is empty."""
586+
with pytest.raises(ValueError, match="network_map cannot be empty"):
587+
Client.for_network({})
588+
589+
590+
@pytest.mark.parametrize(
591+
"invalid_map, error_msg",
592+
[
593+
(
594+
{"127.0.0.1:50211": AccountId(0, 0, 3), "127.0.0.1:50212": AccountId(1, 0, 4)},
595+
"network is not valid, all nodes must be in the same shard and realm",
596+
),
597+
(
598+
{"127.0.0.1:50211": AccountId(0, 0, 3), "127.0.0.1:50212": AccountId(0, 1, 4)},
599+
"network is not valid, all nodes must be in the same shard and realm",
600+
),
601+
],
602+
)
603+
def test_for_network_invalid_shard_realm_raises_error(invalid_map, error_msg):
604+
"""Test that for_network catches mismatched shards or realms."""
605+
with pytest.raises(ValueError, match=error_msg):
606+
Client.for_network(invalid_map)

0 commit comments

Comments
 (0)