2222
2323class ArkivNode :
2424 """
25- Local and fully functional Arkiv development node using Docker containers .
25+ Arkiv node for development and testing - supports both containerized and external nodes .
2626
27- Examples:
28- Context manager (recommended):
27+ This class provides two modes of operation:
28+
29+ 1. **Containerized Node (default)**: Automatically manages a local Docker container
30+ running an Arkiv node for quick prototyping and testing.
31+
32+ 2. **External Node**: Connects to an existing external Arkiv node (e.g., testnet,
33+ mainnet) by providing HTTP and WebSocket URLs.
34+
35+ Note:
36+ Most users don't need to use ArkivNode directly. The Arkiv client automatically
37+ creates and manages a node when instantiated without a provider:
38+
39+ >>> from arkiv import Arkiv
2940 >>> from arkiv.node import ArkivNode
41+ >>> with Arkiv() as client:
42+ ... assert client.is_connected()
43+ ... node: ArkivNode = client.node
44+ ... # do work
45+
46+ Containerized Mode Examples:
47+ Explicit node management:
3048 >>> from arkiv import Arkiv
49+ >>> from arkiv.node import ArkivNode
50+ >>> from arkiv.provider import ProviderBuilder
3151 >>> with ArkivNode() as node:
32- ... arkiv = Arkiv.from_node(node)
52+ ... provider = ProviderBuilder().node(node).build()
53+ ... arkiv = Arkiv(provider)
3354 ... assert arkiv.is_connected()
3455
35- Quick prototyping:
36- >>> node = ArkivNode()
37- >>> arkiv = Arkiv.from_node(node) # Starts automatically
38- >>> # ... do work ...
39- >>> node.stop() # Don't forget cleanup
40-
41- Note:
42- Requires Docker to be running and testcontainers package to be installed:
43- pip install arkiv-sdk[dev]
56+ External Node Examples:
57+ Connect to external network (e.g., testnet/mainnet):
58+ >>> from arkiv import Arkiv
59+ >>> from arkiv.node import ArkivNode
60+ >>> from arkiv.provider import ProviderBuilder
61+ >>> with ArkivNode(http_url="...", ws_url="...") as node:
62+ ... provider = ProviderBuilder().node(node).build()
63+ ... arkiv = Arkiv(provider)
64+ ... # Use arkiv...
65+ ... # No cleanup - external node remains running
66+
67+ Advanced Use Cases:
68+ - Custom Docker images or ports for containerized nodes
69+ - Sharing a single node across multiple test fixtures
70+ - Direct access to container for CLI commands (containerized only)
71+ - Using nodes in pytest fixtures (see tests/conftest.py)
72+
73+ Attributes:
74+ - Containerized nodes require Docker and testcontainers: `pip install arkiv-sdk[dev]`
75+ - External nodes cannot be started, stopped, or have accounts funded via the SDK
76+ - External node accounts must be pre-funded through external means
77+ - Context manager works safely with both modes (no-op for external nodes)
4478 """
4579
4680 DEFAULT_IMAGE = "golemnetwork/golembase-op-geth:latest"
@@ -54,6 +88,8 @@ def __init__(
5488 http_port : int | None = None ,
5589 ws_port : int | None = None ,
5690 auto_start : bool = False ,
91+ http_url : str | None = None ,
92+ ws_url : str | None = None ,
5793 ) -> None :
5894 """
5995 Initialize the Arkiv node.
@@ -63,27 +99,49 @@ def __init__(
6399 http_port: Internal HTTP port (default: 8545)
64100 ws_port: Internal WebSocket port (default: 8546)
65101 auto_start: Automatically start the node on initialization (default: False)
102+ http_url: External HTTP RPC URL (for external nodes, disables container)
103+ ws_url: External WebSocket RPC URL (for external nodes, disables container)
66104
67105 Raises:
68- ImportError: If testcontainers is not installed
106+ ImportError: If testcontainers is not installed (only for containerized nodes)
107+ ValueError: If only one of http_url/ws_url is provided
69108 """
109+ # Initialize common attributes first
110+ self ._container : DockerContainer | None = None
111+ self ._http_url : str = ""
112+ self ._ws_url : str = ""
113+ self ._is_running : bool = False
114+ self ._is_external : bool = False
115+
116+ # Check if this is an external node configuration
117+ if http_url or ws_url :
118+ if not (http_url and ws_url ):
119+ raise ValueError (
120+ "Both http_url and ws_url must be provided for external nodes"
121+ )
122+ # External node configuration - no container needed
123+ self ._image = ""
124+ self ._http_port = 0
125+ self ._ws_port = 0
126+ self ._http_url = http_url
127+ self ._ws_url = ws_url
128+ self ._is_running = True # External nodes are always "running"
129+ self ._is_external = True
130+ return
131+
132+ # Containerized node configuration
70133 try :
71134 import testcontainers # noqa: F401
72135 except ImportError as e :
73136 raise ImportError (
74- "ArkivNode requires testcontainers. "
137+ "ArkivNode requires testcontainers for containerized nodes . "
75138 "Install with: pip install 'arkiv-sdk[dev]' or pip install testcontainers"
76139 ) from e
77140
78141 self ._image = image or self .DEFAULT_IMAGE
79142 self ._http_port = http_port or self .DEFAULT_HTTP_PORT
80143 self ._ws_port = ws_port or self .DEFAULT_WS_PORT
81144
82- self ._container : DockerContainer | None = None
83- self ._http_url : str = ""
84- self ._ws_url : str = ""
85- self ._is_running : bool = False
86-
87145 if auto_start :
88146 self .start ()
89147
@@ -93,7 +151,7 @@ def http_url(self) -> str:
93151 Returns the HTTP RPC endpoint URL.
94152
95153 Raises:
96- RuntimeError: If node is not running
154+ RuntimeError: If node is not running (for containerized nodes only)
97155 """
98156 if not self ._is_running :
99157 raise RuntimeError (
@@ -107,7 +165,7 @@ def ws_url(self) -> str:
107165 Returns the WebSocket RPC endpoint URL.
108166
109167 Raises:
110- RuntimeError: If node is not running
168+ RuntimeError: If node is not running (for containerized nodes only)
111169 """
112170 if not self ._is_running :
113171 raise RuntimeError (
@@ -121,8 +179,10 @@ def http_port(self) -> int:
121179 Returns the mapped HTTP port number.
122180
123181 Raises:
124- RuntimeError: If node is not running
182+ RuntimeError: If node is not running or is an external node
125183 """
184+ if self ._is_external :
185+ raise RuntimeError ("External nodes do not expose port information" )
126186 if not self ._is_running or not self ._container :
127187 raise RuntimeError (
128188 "Node is not running. Call start() first or use context manager."
@@ -135,8 +195,10 @@ def ws_port(self) -> int:
135195 Returns the mapped WebSocket port number.
136196
137197 Raises:
138- RuntimeError: If node is not running
198+ RuntimeError: If node is not running or is an external node
139199 """
200+ if self ._is_external :
201+ raise RuntimeError ("External nodes do not expose port information" )
140202 if not self ._is_running or not self ._container :
141203 raise RuntimeError (
142204 "Node is not running. Call start() first or use context manager."
@@ -152,14 +214,25 @@ def container(self) -> DockerContainer:
152214 The testcontainers DockerContainer instance
153215
154216 Raises:
155- RuntimeError: If node is not running
217+ RuntimeError: If node is an external node or not running
156218 """
219+ if self ._is_external :
220+ raise RuntimeError ("External nodes do not have containers" )
157221 if not self ._container :
158222 raise RuntimeError (
159223 "Container not available. Call start() first or use context manager."
160224 )
161225 return self ._container
162226
227+ def is_external (self ) -> bool :
228+ """
229+ Check if this node is configured as an external node.
230+
231+ Returns:
232+ True if the node is external, False if containerized
233+ """
234+ return self ._is_external
235+
163236 def is_running (self ) -> bool :
164237 """
165238 Check if the node is currently running.
@@ -178,12 +251,20 @@ def start(self) -> None:
178251
179252 Raises:
180253 ImportError: If testcontainers is not installed
254+ RuntimeError: If called on an external node
181255
182256 Note:
183257 This method waits for both HTTP and WebSocket endpoints to be ready
184258 before returning, which may take several seconds.
185259 When the node is already running, this is a no-op.
260+ External nodes cannot be started.
186261 """
262+ # Check if this is an external node
263+ if self ._is_external :
264+ raise RuntimeError (
265+ "Cannot start external node - it is already configured and running"
266+ )
267+
187268 # Immediately return if already running
188269 if self ._is_running :
189270 logger .debug ("Node is already running, nothing to start" )
@@ -195,28 +276,25 @@ def start(self) -> None:
195276 logger .info (f"Starting Arkiv node from image: { self ._image } " )
196277
197278 # Create container
198- self . _container = (
279+ container = (
199280 DockerContainer (self ._image )
200281 .with_exposed_ports (self ._http_port , self ._ws_port )
201282 .with_command (self ._get_command ())
202283 )
203284
204285 # Start container
205- self ._container .start ()
286+ container .start ()
287+ self ._container = container
206288
207289 # Get connection details
208- host = self ._container .get_container_host_ip ()
209- self ._http_url = (
210- f"http://{ host } :{ self ._container .get_exposed_port (self ._http_port )} "
211- )
212- self ._ws_url = f"ws://{ host } :{ self ._container .get_exposed_port (self ._ws_port )} "
290+ host = container .get_container_host_ip ()
291+ self ._http_url = f"http://{ host } :{ container .get_exposed_port (self ._http_port )} "
292+ self ._ws_url = f"ws://{ host } :{ container .get_exposed_port (self ._ws_port )} "
213293
214294 logger .info (f"Arkiv node endpoints: { self ._http_url } | { self ._ws_url } " )
215295
216296 # Wait for services to be ready
217- self ._container .waiting_for (
218- HttpWaitStrategy (self ._http_port ).for_status_code (200 )
219- )
297+ container .waiting_for (HttpWaitStrategy (self ._http_port ).for_status_code (200 ))
220298 self ._wait_for_websocket ()
221299
222300 self ._is_running = True
@@ -228,7 +306,16 @@ def stop(self) -> None:
228306
229307 Stops the Docker container and performs cleanup.
230308 If the node is not running, this is a no-op.
309+
310+ Raises:
311+ RuntimeError: If called on an external node
231312 """
313+ # External nodes cannot be stopped
314+ if self ._is_external :
315+ raise RuntimeError (
316+ "Cannot stop external node - external nodes are managed externally"
317+ )
318+
232319 if not self ._is_running :
233320 logger .debug ("Node is not running, nothing to stop" )
234321 return
@@ -254,7 +341,7 @@ def fund_account(self, account: NamedAccount) -> None:
254341 account: A NamedAccount to fund
255342
256343 Raises:
257- RuntimeError: If the node is not running or funding operations fail
344+ RuntimeError: If the node is not running, is an external node, or funding operations fail
258345
259346 Examples:
260347 Fund a NamedAccount:
@@ -264,6 +351,11 @@ def fund_account(self, account: NamedAccount) -> None:
264351 ... account = NamedAccount.create("alice")
265352 ... node.fund_account(account)
266353 """
354+ if self ._is_external :
355+ raise RuntimeError (
356+ f"Cannot fund account on external node - account { account .name } must be pre-funded via external means"
357+ )
358+
267359 if not self .is_running ():
268360 msg = "Node is not running. Call start() first."
269361 raise RuntimeError (msg )
@@ -362,26 +454,34 @@ def __enter__(self) -> ArkivNode:
362454 """
363455 Context manager entry - start the node.
364456
457+ For external nodes, this is a no-op (already running).
458+ For containerized nodes, this starts the container.
459+
365460 Returns:
366461 Self for use in with statement
367462
368463 Example:
369464 >>> with ArkivNode() as node:
370465 ... print(node.http_url)
371466 """
372- self .start ()
467+ if not self ._is_external :
468+ self .start ()
373469 return self
374470
375471 def __exit__ (self , exc_type : object , exc_val : object , exc_tb : object ) -> None :
376472 """
377473 Context manager exit - stop and cleanup the node.
378474
475+ For external nodes, this is a no-op (managed externally).
476+ For containerized nodes, this stops the container.
477+
379478 Args:
380479 exc_type: Exception type if an error occurred
381480 exc_val: Exception value if an error occurred
382481 exc_tb: Exception traceback if an error occurred
383482 """
384- self .stop ()
483+ if not self ._is_external :
484+ self .stop ()
385485
386486 def __repr__ (self ) -> str :
387487 """
0 commit comments