Skip to content

Commit 29d2893

Browse files
committed
Introduce Python binding test suite and CI job
1 parent 6ade8e4 commit 29d2893

File tree

11 files changed

+357
-3
lines changed

11 files changed

+357
-3
lines changed

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@ jobs:
125125
files: lcov.info
126126
fail_ci_if_error: false
127127

128+
python-test:
129+
name: Python tests
130+
runs-on: ubuntu-latest
131+
steps:
132+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
133+
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7
134+
with:
135+
toolchain: stable
136+
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
137+
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
138+
- run: uv venv && uv pip install maturin pytest pytest-timeout && uv run maturin develop --release && uv run pytest tests/ -v --timeout=30
139+
working-directory: crates/btlightning-py
140+
128141
subtensor:
129142
name: Subtensor integration
130143
runs-on: ubuntu-latest

.gitignore

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ benchmarks/uv.lock
209209
*.gif
210210
plots/
211211

212-
# Test files
213-
test_*.py
214-
*_test.py
212+
# Ad-hoc test scripts (not in test suites)
213+
/test_*.py
214+
/*_test.py
215+
/benchmarks/test_*.py
216+
/benchmarks/*_test.py

crates/btlightning-py/pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ classifiers = [
1717
]
1818
dependencies = ["typing_extensions~=4.0"]
1919

20+
[project.optional-dependencies]
21+
test = ["pytest>=8.0", "pytest-timeout>=2.0"]
22+
23+
[tool.pytest.ini_options]
24+
testpaths = ["tests"]
25+
timeout = 30
26+
2027
[tool.maturin]
2128
python-source = "python"
2229
module-name = "btlightning._native"
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import socket
2+
import threading
3+
import time
4+
5+
import pytest
6+
7+
from btlightning import Lightning, LightningServer
8+
9+
MINER_SEED = bytes([1] * 32)
10+
VALIDATOR_SEED = bytes([2] * 32)
11+
MINER_HOTKEY = "5CcyqxXnJucaCnQQvvUg5EPzj1uoNAxACZvzArHw5aVDvgNH"
12+
VALIDATOR_HOTKEY = "5CfCr47V5Dte6bwxNBE8K9oNnQd9fiay6aDEEkgYtFv7w4Fq"
13+
14+
15+
@pytest.fixture()
16+
def free_port():
17+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
18+
s.bind(("127.0.0.1", 0))
19+
return s.getsockname()[1]
20+
21+
22+
@pytest.fixture()
23+
def echo_server(free_port):
24+
server = LightningServer(miner_hotkey=MINER_HOTKEY, host="127.0.0.1", port=free_port)
25+
server.set_miner_keypair(MINER_SEED)
26+
server.register_synapse_handler("echo", lambda data: data)
27+
server.start()
28+
t = threading.Thread(target=server.serve_forever, daemon=True)
29+
t.start()
30+
time.sleep(0.05)
31+
yield server, free_port
32+
server.stop()
33+
34+
35+
@pytest.fixture()
36+
def client_and_axon(echo_server):
37+
_, port = echo_server
38+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
39+
client.set_validator_keypair(VALIDATOR_SEED)
40+
axon = {"hotkey": MINER_HOTKEY, "ip": "127.0.0.1", "port": port}
41+
client.initialize_connections([axon])
42+
yield client, axon
43+
client.close()
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from btlightning import Lightning
2+
3+
from conftest import VALIDATOR_HOTKEY, VALIDATOR_SEED
4+
5+
6+
def test_constructor_defaults():
7+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
8+
assert client.wallet_hotkey == VALIDATOR_HOTKEY
9+
client.close()
10+
11+
12+
def test_constructor_custom_config():
13+
client = Lightning(
14+
wallet_hotkey=VALIDATOR_HOTKEY,
15+
connect_timeout_secs=5,
16+
idle_timeout_secs=30,
17+
keep_alive_interval_secs=10,
18+
reconnect_initial_backoff_secs=1,
19+
reconnect_max_backoff_secs=60,
20+
reconnect_max_retries=3,
21+
max_frame_payload_bytes=65536,
22+
max_stream_payload_bytes=1048576,
23+
)
24+
assert client.wallet_hotkey == VALIDATOR_HOTKEY
25+
client.close()
26+
27+
28+
def test_set_validator_keypair():
29+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
30+
client.set_validator_keypair(VALIDATOR_SEED)
31+
client.close()
32+
33+
34+
def test_set_python_signer():
35+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
36+
client.set_python_signer(lambda msg: b"\x00" * 64)
37+
client.close()
38+
39+
40+
def test_get_connection_stats_empty():
41+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
42+
stats = client.get_connection_stats()
43+
assert "total_connections" in stats
44+
assert "active_miners" in stats
45+
client.close()
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import pytest
2+
3+
from btlightning import Lightning
4+
5+
from conftest import MINER_HOTKEY, VALIDATOR_HOTKEY
6+
7+
8+
def test_missing_synapse_type(client_and_axon):
9+
client, axon = client_and_axon
10+
with pytest.raises(KeyError, match="synapse_type"):
11+
client.query_axon(axon, {"data": {"msg": "hello"}})
12+
13+
14+
def test_missing_hotkey(client_and_axon):
15+
client, _ = client_and_axon
16+
with pytest.raises(KeyError, match="hotkey"):
17+
client.query_axon({"ip": "127.0.0.1", "port": 1234}, {"synapse_type": "echo", "data": {}})
18+
19+
20+
def test_missing_ip(client_and_axon):
21+
client, _ = client_and_axon
22+
with pytest.raises(KeyError, match="ip"):
23+
client.query_axon({"hotkey": MINER_HOTKEY, "port": 1234}, {"synapse_type": "echo", "data": {}})
24+
25+
26+
def test_missing_port(client_and_axon):
27+
client, _ = client_and_axon
28+
with pytest.raises(KeyError, match="port"):
29+
client.query_axon({"hotkey": MINER_HOTKEY, "ip": "127.0.0.1"}, {"synapse_type": "echo", "data": {}})
30+
31+
32+
def test_query_without_signer():
33+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
34+
axon = {"hotkey": MINER_HOTKEY, "ip": "127.0.0.1", "port": 9999}
35+
with pytest.raises(Exception):
36+
client.query_axon(axon, {"synapse_type": "echo", "data": {}})
37+
client.close()
38+
39+
40+
def test_invalid_timeout(client_and_axon):
41+
client, axon = client_and_axon
42+
with pytest.raises(Exception):
43+
client.query_axon(axon, {"synapse_type": "echo", "data": {}}, timeout_secs=-1.0)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import socket
2+
import threading
3+
import time
4+
5+
from btlightning import Lightning, LightningServer
6+
7+
from conftest import MINER_HOTKEY, MINER_SEED, VALIDATOR_HOTKEY, VALIDATOR_SEED
8+
9+
10+
def test_two_handlers_on_same_server():
11+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
12+
s.bind(("127.0.0.1", 0))
13+
port = s.getsockname()[1]
14+
15+
def upper_handler(data):
16+
return {"result": data["text"].upper()}
17+
18+
server = LightningServer(miner_hotkey=MINER_HOTKEY, host="127.0.0.1", port=port)
19+
server.set_miner_keypair(MINER_SEED)
20+
server.register_synapse_handler("echo", lambda data: data)
21+
server.register_synapse_handler("upper", upper_handler)
22+
server.start()
23+
t = threading.Thread(target=server.serve_forever, daemon=True)
24+
t.start()
25+
time.sleep(0.05)
26+
27+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
28+
client.set_validator_keypair(VALIDATOR_SEED)
29+
axon = {"hotkey": MINER_HOTKEY, "ip": "127.0.0.1", "port": port}
30+
client.initialize_connections([axon])
31+
32+
echo_resp = client.query_axon(axon, {"synapse_type": "echo", "data": {"msg": "hi"}})
33+
assert echo_resp["success"] is True
34+
assert echo_resp["data"]["msg"] == "hi"
35+
36+
upper_resp = client.query_axon(axon, {"synapse_type": "upper", "data": {"text": "hello"}})
37+
assert upper_resp["success"] is True
38+
assert upper_resp["data"]["result"] == "HELLO"
39+
40+
client.close()
41+
server.stop()
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
def test_echo_roundtrip(client_and_axon):
2+
client, axon = client_and_axon
3+
resp = client.query_axon(axon, {"synapse_type": "echo", "data": {"msg": "hello"}})
4+
assert resp["success"] is True
5+
assert resp["error"] is None
6+
assert resp["data"]["msg"] == "hello"
7+
assert resp["latency_ms"] >= 0
8+
9+
10+
def test_large_payload(client_and_axon):
11+
client, axon = client_and_axon
12+
payload = b"\x42" * 100_000
13+
resp = client.query_axon(axon, {"synapse_type": "echo", "data": {"payload": payload}})
14+
assert resp["success"] is True
15+
assert resp["data"]["payload"] == payload
16+
17+
18+
def test_query_with_timeout(client_and_axon):
19+
client, axon = client_and_axon
20+
resp = client.query_axon(
21+
axon,
22+
{"synapse_type": "echo", "data": {"val": 42}},
23+
timeout_secs=5.0,
24+
)
25+
assert resp["success"] is True
26+
assert resp["data"]["val"] == 42
27+
28+
29+
def test_multiple_sequential_queries(client_and_axon):
30+
client, axon = client_and_axon
31+
for i in range(10):
32+
resp = client.query_axon(axon, {"synapse_type": "echo", "data": {"i": i}})
33+
assert resp["success"] is True
34+
assert resp["data"]["i"] == i
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import socket
2+
import threading
3+
import time
4+
5+
from btlightning import LightningServer
6+
7+
from conftest import MINER_HOTKEY, MINER_SEED
8+
9+
10+
def test_constructor_defaults():
11+
server = LightningServer(miner_hotkey=MINER_HOTKEY, host="127.0.0.1", port=0)
12+
server.stop()
13+
14+
15+
def test_constructor_custom_config():
16+
server = LightningServer(
17+
miner_hotkey=MINER_HOTKEY,
18+
host="127.0.0.1",
19+
port=0,
20+
max_signature_age_secs=60,
21+
idle_timeout_secs=30,
22+
keep_alive_interval_secs=10,
23+
nonce_cleanup_interval_secs=120,
24+
max_nonce_entries=5000,
25+
max_frame_payload_bytes=65536,
26+
)
27+
server.stop()
28+
29+
30+
def test_start_stop_lifecycle():
31+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
32+
s.bind(("127.0.0.1", 0))
33+
port = s.getsockname()[1]
34+
35+
server = LightningServer(miner_hotkey=MINER_HOTKEY, host="127.0.0.1", port=port)
36+
server.set_miner_keypair(MINER_SEED)
37+
server.register_synapse_handler("echo", lambda data: data)
38+
server.start()
39+
t = threading.Thread(target=server.serve_forever, daemon=True)
40+
t.start()
41+
time.sleep(0.05)
42+
43+
stats = server.get_connection_stats()
44+
assert "total_connections" in stats
45+
assert "verified_connections" in stats
46+
47+
server.stop()
48+
49+
50+
def test_get_connection_stats():
51+
server = LightningServer(miner_hotkey=MINER_HOTKEY, host="127.0.0.1", port=0)
52+
server.set_miner_keypair(MINER_SEED)
53+
server.register_synapse_handler("echo", lambda data: data)
54+
server.start()
55+
t = threading.Thread(target=server.serve_forever, daemon=True)
56+
t.start()
57+
time.sleep(0.05)
58+
59+
stats = server.get_connection_stats()
60+
assert stats["total_connections"] == "0"
61+
assert stats["verified_connections"] == "0"
62+
63+
server.stop()
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import socket
2+
import threading
3+
import time
4+
5+
from btlightning import Lightning, LightningServer
6+
7+
from conftest import MINER_HOTKEY, MINER_SEED, VALIDATOR_HOTKEY, VALIDATOR_SEED
8+
9+
10+
def test_streaming_handler():
11+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
12+
s.bind(("127.0.0.1", 0))
13+
port = s.getsockname()[1]
14+
15+
chunks = [b"chunk-1", b"chunk-2", b"chunk-3"]
16+
17+
def stream_handler(data):
18+
return iter(chunks)
19+
20+
server = LightningServer(miner_hotkey=MINER_HOTKEY, host="127.0.0.1", port=port)
21+
server.set_miner_keypair(MINER_SEED)
22+
server.register_streaming_handler("stream", stream_handler)
23+
server.start()
24+
t = threading.Thread(target=server.serve_forever, daemon=True)
25+
t.start()
26+
time.sleep(0.05)
27+
28+
client = Lightning(wallet_hotkey=VALIDATOR_HOTKEY)
29+
client.set_validator_keypair(VALIDATOR_SEED)
30+
axon = {"hotkey": MINER_HOTKEY, "ip": "127.0.0.1", "port": port}
31+
client.initialize_connections([axon])
32+
33+
stream = client.query_axon_stream(axon, {"synapse_type": "stream", "data": {}})
34+
received = list(stream)
35+
assert received == chunks
36+
37+
client.close()
38+
server.stop()

0 commit comments

Comments
 (0)