Skip to content

Commit b878c06

Browse files
authored
Merge pull request #24 from MinaProtocol/dkijania/quality-improvements
2 parents 1a431d9 + b9f1279 commit b878c06

11 files changed

Lines changed: 584 additions & 104 deletions

File tree

.github/workflows/integration.yml

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ on:
1010
jobs:
1111
integration:
1212
runs-on: ubuntu-latest
13-
timeout-minutes: 15
13+
timeout-minutes: 20
1414

1515
services:
1616
mina:
@@ -24,6 +24,7 @@ jobs:
2424
ports:
2525
- 8080:8080
2626
- 8181:8181
27+
- 8282:8282
2728
- 3085:3085
2829

2930
steps:
@@ -49,10 +50,32 @@ jobs:
4950
- name: Acquire test accounts
5051
id: accounts
5152
run: |
52-
SENDER=$(curl -s http://127.0.0.1:8181/acquire-account?unlockAccount=true)
53-
RECEIVER=$(curl -s http://127.0.0.1:8181/acquire-account)
54-
SENDER_PK=$(echo "$SENDER" | python -c "import sys,json; print(json.load(sys.stdin)['pk'])")
55-
RECEIVER_PK=$(echo "$RECEIVER" | python -c "import sys,json; print(json.load(sys.stdin)['pk'])")
53+
# Helper: retry curl until we get a valid account with 'pk' field
54+
acquire_account() {
55+
local url="$1"
56+
local label="$2"
57+
for i in $(seq 1 60); do
58+
RESP=$(curl -s "$url" 2>&1 || echo "")
59+
if [ -n "$RESP" ]; then
60+
PK=$(echo "$RESP" | python -c "import sys,json; d=json.load(sys.stdin); print(d.get('pk',''))" 2>/dev/null)
61+
if [ -n "$PK" ]; then
62+
echo "$PK"
63+
return 0
64+
fi
65+
fi
66+
echo "Attempt $i/60: $label not ready yet (response: $RESP)" >&2
67+
sleep 5
68+
done
69+
echo "Failed to acquire $label after 60 attempts" >&2
70+
return 1
71+
}
72+
73+
# Debug: check which ports are responding
74+
echo "Checking port 8181..." && curl -sf http://127.0.0.1:8181/ 2>&1 | head -1 || echo "8181 not responding"
75+
echo "Checking port 8282..." && curl -sf http://127.0.0.1:8282/ 2>&1 | head -1 || echo "8282 not responding"
76+
77+
SENDER_PK=$(acquire_account "http://127.0.0.1:8181/acquire-account?unlockAccount=true" "sender")
78+
RECEIVER_PK=$(acquire_account "http://127.0.0.1:8181/acquire-account" "receiver")
5679
echo "sender_pk=$SENDER_PK" >> "$GITHUB_OUTPUT"
5780
echo "receiver_pk=$RECEIVER_PK" >> "$GITHUB_OUTPUT"
5881
echo "Sender: $SENDER_PK"

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
### Added
11+
- `DaemonConnectionError` exception (replaces shadowed `ConnectionError`)
12+
- `Currency.__rmul__` for `3 * Currency(10)` support
13+
- Export all public types from `mina_sdk`: `AccountBalance`, `AccountData`,
14+
`BlockInfo`, `DaemonStatus`, `PeerInfo`, `SendPaymentResult`,
15+
`SendDelegationResult`, `GraphQLError`, `DaemonConnectionError`,
16+
`CurrencyUnderflow`
17+
- Input validation for `MinaDaemonClient` configuration parameters
18+
- Docstrings on all public classes, methods, and dataclass fields
19+
- 11 new unit tests (49 total)
20+
21+
### Fixed
22+
- HTTP status code now checked before parsing JSON response body
23+
- JSON decode errors are caught and wrapped as `DaemonConnectionError`
24+
- Negative `Currency` values are rejected with `ValueError`
25+
26+
### Changed
27+
- `Currency.__mul__` only accepts `int` scalars (was also accepting `Currency`,
28+
which produced nonsensical nanomina-squared units)
29+
- `ConnectionError` is now an alias for `DaemonConnectionError` (backwards compatible)
30+
31+
## [0.1.0] - 2025-11-20
32+
33+
### Added
34+
- Initial release
35+
- `MinaDaemonClient` with GraphQL queries and mutations
36+
- `Currency` type with nanomina-precision arithmetic
37+
- Typed response dataclasses
38+
- Automatic retry with configurable backoff
39+
- Context manager support
40+
- Unit tests with HTTP mocking
41+
- Integration tests against live daemon
42+
- CI workflows: lint, test, release, integration, schema drift
43+
- PyPI publishing via trusted publishers

README.md

Lines changed: 93 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ Python SDK for interacting with [Mina Protocol](https://minaprotocol.com) nodes.
44

55
## Features
66

7-
- **Daemon GraphQL client** query node status, accounts, blocks; send payments and delegations
7+
- **Daemon GraphQL client** -- query node status, accounts, blocks; send payments and delegations
88
- Typed response objects with `Currency` arithmetic
99
- Automatic retry with configurable backoff
10-
- Context manager support
10+
- Context manager support for clean resource management
1111

1212
## Installation
1313

@@ -49,34 +49,34 @@ with MinaDaemonClient() as client:
4949
```python
5050
client = MinaDaemonClient(
5151
graphql_uri="http://127.0.0.1:3085/graphql", # default
52-
retries=3, # retry failed requests
53-
retry_delay=5.0, # seconds between retries
54-
timeout=30.0, # HTTP timeout in seconds
52+
retries=3, # retry failed requests (must be >= 1)
53+
retry_delay=5.0, # seconds between retries (must be >= 0)
54+
timeout=30.0, # HTTP timeout in seconds (must be > 0)
5555
)
5656
```
5757

5858
## API Reference
5959

6060
### Queries
6161

62-
| Method | Description |
63-
|--------|-------------|
64-
| `get_sync_status()` | Node sync status (SYNCED, BOOTSTRAP, etc.) |
65-
| `get_daemon_status()` | Comprehensive daemon status |
66-
| `get_network_id()` | Network identifier |
67-
| `get_account(public_key)` | Account balance, nonce, delegate |
68-
| `get_best_chain(max_length)` | Recent blocks from best chain |
69-
| `get_peers()` | Connected peers |
70-
| `get_pooled_user_commands(public_key)` | Pending transactions |
62+
| Method | Returns | Description |
63+
|--------|---------|-------------|
64+
| `get_sync_status()` | `str` | Node sync status (SYNCED, BOOTSTRAP, etc.) |
65+
| `get_daemon_status()` | `DaemonStatus` | Comprehensive daemon status |
66+
| `get_network_id()` | `str` | Network identifier |
67+
| `get_account(public_key)` | `AccountData` | Account balance, nonce, delegate |
68+
| `get_best_chain(max_length)` | `list[BlockInfo]` | Recent blocks from best chain |
69+
| `get_peers()` | `list[PeerInfo]` | Connected peers |
70+
| `get_pooled_user_commands(public_key)` | `list[dict]` | Pending transactions |
7171

7272
### Mutations
7373

74-
| Method | Description |
75-
|--------|-------------|
76-
| `send_payment(sender, receiver, amount, fee)` | Send a payment |
77-
| `send_delegation(sender, delegate_to, fee)` | Delegate stake |
78-
| `set_snark_worker(public_key)` | Set/unset SNARK worker |
79-
| `set_snark_work_fee(fee)` | Set SNARK work fee |
74+
| Method | Returns | Description |
75+
|--------|---------|-------------|
76+
| `send_payment(sender, receiver, amount, fee)` | `SendPaymentResult` | Send a payment |
77+
| `send_delegation(sender, delegate_to, fee)` | `SendDelegationResult` | Delegate stake |
78+
| `set_snark_worker(public_key)` | `str \| None` | Set/unset SNARK worker |
79+
| `set_snark_work_fee(fee)` | `str` | Set SNARK work fee |
8080

8181
### Currency
8282

@@ -90,6 +90,51 @@ c = Currency.from_nanomina(1_000_000_000) # 1 MINA
9090
print(a + b) # 11.500000000
9191
print(a.nanomina) # 10000000000
9292
print(a > b) # True
93+
print(3 * b) # 4.500000000
94+
```
95+
96+
### Error Handling
97+
98+
```python
99+
from mina_sdk import MinaDaemonClient, GraphQLError, DaemonConnectionError, CurrencyUnderflow
100+
101+
with MinaDaemonClient(retries=3, retry_delay=2.0) as client:
102+
try:
103+
account = client.get_account("B62q...")
104+
except ValueError as e:
105+
# Account not found on ledger
106+
print(f"Account does not exist: {e}")
107+
except GraphQLError as e:
108+
# Daemon returned a GraphQL-level error
109+
print(f"GraphQL error: {e}")
110+
print(f"Raw errors: {e.errors}")
111+
except DaemonConnectionError as e:
112+
# All retry attempts exhausted
113+
print(f"Cannot reach daemon after retries: {e}")
114+
115+
# Currency underflow
116+
from mina_sdk import Currency, CurrencyUnderflow
117+
118+
try:
119+
result = Currency(1) - Currency(2)
120+
except CurrencyUnderflow:
121+
print("Subtraction would result in negative balance")
122+
```
123+
124+
### Data Types
125+
126+
All response types are importable from the top-level package:
127+
128+
```python
129+
from mina_sdk import (
130+
AccountBalance, # total, liquid, locked balances
131+
AccountData, # public_key, nonce, balance, delegate, token_id
132+
BlockInfo, # state_hash, height, slots, creator, tx count
133+
DaemonStatus, # sync_status, chain height, peers, uptime
134+
PeerInfo, # peer_id, host, port
135+
SendPaymentResult, # id, hash, nonce
136+
SendDelegationResult, # id, hash, nonce
137+
)
93138
```
94139

95140
## Development
@@ -102,6 +147,34 @@ pip install -e ".[dev]"
102147
pytest
103148
```
104149

150+
### Running integration tests
151+
152+
Integration tests require a running Mina daemon:
153+
154+
```bash
155+
MINA_GRAPHQL_URI=http://127.0.0.1:3085/graphql \
156+
MINA_TEST_SENDER_KEY=B62q... \
157+
MINA_TEST_RECEIVER_KEY=B62q... \
158+
pytest tests/test_integration.py -v
159+
```
160+
161+
## Troubleshooting
162+
163+
**Connection refused / DaemonConnectionError**
164+
165+
The daemon is not running or not reachable at the configured URI. Check:
166+
- Is the daemon running? (`mina client status`)
167+
- Is the GraphQL port open? (default: 3085)
168+
- Is `--insecure-rest-server` set if connecting from a different host?
169+
170+
**GraphQLError: field not found**
171+
172+
The SDK's queries may be out of sync with the daemon's GraphQL schema. This can happen after a daemon upgrade. Check the [schema drift CI](https://github.com/MinaProtocol/mina-sdk-python/actions/workflows/schema-drift.yml) for compatibility status.
173+
174+
**Account not found**
175+
176+
`get_account()` raises `ValueError` when the account doesn't exist on the ledger. This is normal for new accounts that haven't received any transactions yet.
177+
105178
## License
106179

107180
Apache License 2.0

examples/basic_usage.py

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
"""Basic usage of the Mina Python SDK."""
22

3-
from mina_sdk import MinaDaemonClient, Currency
3+
from mina_sdk import (
4+
Currency,
5+
DaemonConnectionError,
6+
GraphQLError,
7+
MinaDaemonClient,
8+
)
49

510

611
def main():
12+
"""Demonstrate core SDK features against a local daemon."""
713
# Connect to a local Mina daemon (default: http://127.0.0.1:3085/graphql)
814
with MinaDaemonClient() as client:
915
# Check sync status
@@ -19,41 +25,52 @@ def main():
1925
network_id = client.get_network_id()
2026
print(f"Network: {network_id}")
2127

22-
# Query an account
23-
account = client.get_account("B62qrPN5Y5yq8kGE3FbVKbGTdTAJNdtNtS5vH1tH...")
24-
print(f"Balance: {account.balance.total} MINA")
25-
print(f"Nonce: {account.nonce}")
28+
# Query an account (replace with a real public key)
29+
try:
30+
account = client.get_account("B62qrPN5Y5yq8kGE3FbVKbGTdTAJNdtNtS5vH1tH...")
31+
print(f"Balance: {account.balance.total} MINA")
32+
print(f"Nonce: {account.nonce}")
33+
except ValueError as e:
34+
print(f"Account not found: {e}")
2635

2736
# Get recent blocks
2837
blocks = client.get_best_chain(max_length=5)
2938
for block in blocks:
30-
print(f"Block {block.height}: {block.state_hash[:20]}... "
31-
f"({block.command_transaction_count} txns)")
39+
print(
40+
f"Block {block.height}: {block.state_hash[:20]}... "
41+
f"({block.command_transaction_count} txns)"
42+
)
3243

3344
# Send a payment (requires sender account unlocked on node)
34-
result = client.send_payment(
35-
sender="B62qsender...",
36-
receiver="B62qreceiver...",
37-
amount=Currency("1.5"), # 1.5 MINA
38-
fee=Currency("0.01"), # 0.01 MINA fee
39-
memo="hello from SDK",
40-
)
41-
print(f"Payment sent! Hash: {result.hash}, Nonce: {result.nonce}")
45+
try:
46+
result = client.send_payment(
47+
sender="B62qsender...",
48+
receiver="B62qreceiver...",
49+
amount=Currency("1.5"), # 1.5 MINA
50+
fee=Currency("0.01"), # 0.01 MINA fee
51+
memo="hello from SDK",
52+
)
53+
print(f"Payment sent! Hash: {result.hash}, Nonce: {result.nonce}")
54+
except GraphQLError as e:
55+
print(f"Payment failed: {e}")
4256

4357

4458
def connect_to_remote_node():
45-
"""Connect to a remote daemon."""
46-
client = MinaDaemonClient(
47-
graphql_uri="http://my-mina-node:3085/graphql",
48-
retries=5,
49-
retry_delay=10.0,
50-
timeout=60.0,
51-
)
59+
"""Connect to a remote daemon with custom retry settings."""
5260
try:
53-
status = client.get_sync_status()
54-
print(f"Remote node status: {status}")
55-
finally:
56-
client.close()
61+
client = MinaDaemonClient(
62+
graphql_uri="http://my-mina-node:3085/graphql",
63+
retries=5,
64+
retry_delay=10.0,
65+
timeout=60.0,
66+
)
67+
try:
68+
status = client.get_sync_status()
69+
print(f"Remote node status: {status}")
70+
finally:
71+
client.close()
72+
except DaemonConnectionError as e:
73+
print(f"Could not reach remote node: {e}")
5774

5875

5976
if __name__ == "__main__":

0 commit comments

Comments
 (0)