Skip to content

Commit ff3c544

Browse files
author
Matthias Zimmermann
committed
add external node support to ArkivNode, refactor test suite to use new ArkivNode
1 parent 9584ef1 commit ff3c544

File tree

8 files changed

+230
-360
lines changed

8 files changed

+230
-360
lines changed

.env.testing

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
# Copy this to .env and modify as needed
33

44
# External Node Configuration (uncomment to use external node instead of testcontainers)
5-
RPC_URL=https://kaolin.hoodi.arkiv.network/rpc
6-
WS_URL=wss://kaolin.hoodi.arkiv.network/rpc/ws
5+
RPC_URL=https://intproxy.hoodi.arkiv.network/rpc
6+
WS_URL=wss://intproxy.hoodi.arkiv.network/rpc/ws
77

88
# External Wallet Configuration (required when using external node)
99
WALLET_FILE_1=wallet_alice.json
@@ -49,8 +49,10 @@ WALLET_PASSWORD_19=s3cret
4949
WALLET_PASSWORD_20=s3cret
5050

5151
# Parallel operations configuration
52-
# E.g. test_entity_create_parallel.py
53-
NUM_CLIENTS = 20
54-
NUM_TX = 10
55-
BATCH_SIZE = 100
56-
VERIFY_SAMPLE_SIZE = 5
52+
# E.g. uv run pytest -k test_parallel_entity_creation --log-cli-level=info
53+
NUM_CLIENTS = 2
54+
NUM_TX = 5
55+
# BATCH_SIZE = 120
56+
BATCH_SIZE = 3
57+
# VERIFY_SAMPLE_SIZE < 0 means verify all
58+
VERIFY_SAMPLE_SIZE = 3

src/arkiv/node.py

Lines changed: 138 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,25 +22,59 @@
2222

2323
class 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

Comments
 (0)