oneshot-python 0.6.0__tar.gz → 0.7.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {oneshot_python-0.6.0 → oneshot_python-0.7.1}/.gitignore +3 -0
- {oneshot_python-0.6.0 → oneshot_python-0.7.1}/PKG-INFO +1 -1
- {oneshot_python-0.6.0 → oneshot_python-0.7.1}/oneshot/client.py +17 -2
- {oneshot_python-0.6.0 → oneshot_python-0.7.1}/oneshot/x402.py +38 -14
- {oneshot_python-0.6.0 → oneshot_python-0.7.1}/pyproject.toml +1 -1
- oneshot_python-0.7.1/tests/test_balance.py +98 -0
- {oneshot_python-0.6.0 → oneshot_python-0.7.1}/tests/test_x402.py +10 -7
- {oneshot_python-0.6.0 → oneshot_python-0.7.1}/uv.lock +1 -1
- {oneshot_python-0.6.0 → oneshot_python-0.7.1}/README.md +0 -0
- {oneshot_python-0.6.0 → oneshot_python-0.7.1}/oneshot/__init__.py +0 -0
- {oneshot_python-0.6.0 → oneshot_python-0.7.1}/oneshot/_errors.py +0 -0
- {oneshot_python-0.6.0 → oneshot_python-0.7.1}/tests/__init__.py +0 -0
|
@@ -137,9 +137,10 @@ class OneShotClient:
|
|
|
137
137
|
|
|
138
138
|
# Step 2 — Parse 402 response
|
|
139
139
|
# Parse x402 v2 PaymentRequired from PAYMENT-REQUIRED header
|
|
140
|
-
|
|
140
|
+
parsed_req = parse_payment_required(
|
|
141
141
|
resp.headers.get("payment-required")
|
|
142
142
|
)
|
|
143
|
+
accepted = parsed_req["accepted"]
|
|
143
144
|
|
|
144
145
|
quote_data = resp.json()
|
|
145
146
|
payment_request = quote_data["payment_request"]
|
|
@@ -156,7 +157,7 @@ class OneShotClient:
|
|
|
156
157
|
|
|
157
158
|
self._log(f"Payment required: {payment_request['amount']} USDC")
|
|
158
159
|
|
|
159
|
-
# Step 3 — Sign x402 payment (v2 format with accepted requirements)
|
|
160
|
+
# Step 3 — Sign x402 payment (v2 format with accepted requirements + Bazaar metadata)
|
|
160
161
|
auth = sign_payment_authorization(
|
|
161
162
|
private_key=self._private_key,
|
|
162
163
|
from_address=self.address,
|
|
@@ -166,6 +167,8 @@ class OneShotClient:
|
|
|
166
167
|
chain_id=payment_request["chain_id"],
|
|
167
168
|
network=f"eip155:{payment_request['chain_id']}",
|
|
168
169
|
accepted=accepted,
|
|
170
|
+
resource=parsed_req.get("resource"),
|
|
171
|
+
extensions=parsed_req.get("extensions"),
|
|
169
172
|
)
|
|
170
173
|
|
|
171
174
|
# Step 4 — Re-POST with payment headers (x402 format)
|
|
@@ -275,6 +278,18 @@ class OneShotClient:
|
|
|
275
278
|
return {"success": True}
|
|
276
279
|
return resp.json()
|
|
277
280
|
|
|
281
|
+
# ------------------------------------------------------------------
|
|
282
|
+
# Balance
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
def get_balance(self) -> dict:
|
|
286
|
+
"""Get unified balance (on-chain USDC + credits). Blocking."""
|
|
287
|
+
return self.call_free_get("/v1/tools/balance")
|
|
288
|
+
|
|
289
|
+
async def aget_balance(self) -> dict:
|
|
290
|
+
"""Get unified balance (on-chain USDC + credits). Async."""
|
|
291
|
+
return await self.acall_free_get("/v1/tools/balance")
|
|
292
|
+
|
|
278
293
|
# ------------------------------------------------------------------
|
|
279
294
|
# Job polling
|
|
280
295
|
# ------------------------------------------------------------------
|
|
@@ -39,8 +39,16 @@ class PaymentAuthorization(TypedDict):
|
|
|
39
39
|
payload: dict[str, Any]
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
"""
|
|
42
|
+
class ParsedPaymentRequired(TypedDict, total=False):
|
|
43
|
+
"""Full parsed 402 response with Bazaar discovery metadata."""
|
|
44
|
+
|
|
45
|
+
accepted: PaymentRequirements
|
|
46
|
+
resource: dict[str, Any]
|
|
47
|
+
extensions: dict[str, Any]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_payment_required(header: str | None) -> ParsedPaymentRequired:
|
|
51
|
+
"""Decode the base64 PAYMENT-REQUIRED header and return accepted requirements + Bazaar metadata.
|
|
44
52
|
|
|
45
53
|
Falls back to sensible defaults if the header is missing or unparseable.
|
|
46
54
|
"""
|
|
@@ -48,22 +56,29 @@ def parse_payment_required(header: str | None) -> PaymentRequirements:
|
|
|
48
56
|
try:
|
|
49
57
|
decoded = base64.b64decode(header).decode()
|
|
50
58
|
parsed = json.loads(decoded)
|
|
51
|
-
# x402 v2: { x402Version: 2, accepts: [...], resource: {...} }
|
|
59
|
+
# x402 v2: { x402Version: 2, accepts: [...], resource: {...}, extensions: {...} }
|
|
52
60
|
accepts = parsed.get("accepts", [])
|
|
53
61
|
if accepts:
|
|
54
|
-
|
|
62
|
+
result: ParsedPaymentRequired = {"accepted": accepts[0]}
|
|
63
|
+
if parsed.get("resource"):
|
|
64
|
+
result["resource"] = parsed["resource"]
|
|
65
|
+
if parsed.get("extensions"):
|
|
66
|
+
result["extensions"] = parsed["extensions"]
|
|
67
|
+
return result
|
|
55
68
|
except Exception:
|
|
56
69
|
pass
|
|
57
70
|
|
|
58
71
|
# Fallback with production defaults
|
|
59
|
-
return
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
return ParsedPaymentRequired(
|
|
73
|
+
accepted=PaymentRequirements(
|
|
74
|
+
scheme="exact",
|
|
75
|
+
network="eip155:8453",
|
|
76
|
+
amount="0",
|
|
77
|
+
asset="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
78
|
+
payTo="",
|
|
79
|
+
maxTimeoutSeconds=300,
|
|
80
|
+
extra={"name": "USD Coin", "version": "2"},
|
|
81
|
+
),
|
|
67
82
|
)
|
|
68
83
|
|
|
69
84
|
|
|
@@ -77,6 +92,8 @@ def sign_payment_authorization(
|
|
|
77
92
|
chain_id: int,
|
|
78
93
|
network: str,
|
|
79
94
|
accepted: PaymentRequirements | None = None,
|
|
95
|
+
resource: dict[str, Any] | None = None,
|
|
96
|
+
extensions: dict[str, Any] | None = None,
|
|
80
97
|
) -> dict[str, Any]:
|
|
81
98
|
"""Create an EIP-712 TransferWithAuthorization signature for USDC.
|
|
82
99
|
|
|
@@ -89,6 +106,8 @@ def sign_payment_authorization(
|
|
|
89
106
|
chain_id: EVM chain ID (8453 for Base Mainnet).
|
|
90
107
|
network: CAIP-2 network string (e.g. "eip155:8453").
|
|
91
108
|
accepted: Parsed PaymentRequirements from server's PAYMENT-REQUIRED header.
|
|
109
|
+
resource: Resource metadata from 402 response (for Bazaar discovery).
|
|
110
|
+
extensions: Extensions from 402 response (for Bazaar discovery).
|
|
92
111
|
|
|
93
112
|
Returns:
|
|
94
113
|
x402 PaymentPayload v2 dict.
|
|
@@ -150,8 +169,8 @@ def sign_payment_authorization(
|
|
|
150
169
|
extra={"name": domain_name, "version": domain_version},
|
|
151
170
|
)
|
|
152
171
|
|
|
153
|
-
# x402 PaymentPayload v2 format
|
|
154
|
-
|
|
172
|
+
# x402 PaymentPayload v2 format (including resource + extensions for Bazaar discovery)
|
|
173
|
+
result: dict[str, Any] = {
|
|
155
174
|
"x402Version": 2,
|
|
156
175
|
"accepted": dict(accepted_req),
|
|
157
176
|
"payload": {
|
|
@@ -166,6 +185,11 @@ def sign_payment_authorization(
|
|
|
166
185
|
},
|
|
167
186
|
},
|
|
168
187
|
}
|
|
188
|
+
if resource:
|
|
189
|
+
result["resource"] = resource
|
|
190
|
+
if extensions:
|
|
191
|
+
result["extensions"] = extensions
|
|
192
|
+
return result
|
|
169
193
|
|
|
170
194
|
|
|
171
195
|
def encode_payment_header(auth: dict[str, Any]) -> str:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "oneshot-python"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.7.1"
|
|
4
4
|
description = "Core Python SDK for the OneShot API — HTTP client with x402 payment handling"
|
|
5
5
|
readme = {text = "Core Python SDK for the OneShot API", content-type = "text/plain"}
|
|
6
6
|
license = "MIT"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tests for OneShotClient.get_balance() and aget_balance().
|
|
2
|
+
|
|
3
|
+
Uses mock HTTP responses to verify the client correctly calls the
|
|
4
|
+
/v1/tools/balance endpoint and returns the parsed response.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
from eth_account import Account
|
|
14
|
+
|
|
15
|
+
from oneshot.client import OneShotClient
|
|
16
|
+
|
|
17
|
+
# Deterministic test key (never use with real funds)
|
|
18
|
+
TEST_PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
|
|
19
|
+
TEST_ADDRESS = Account.from_key(TEST_PRIVATE_KEY).address
|
|
20
|
+
|
|
21
|
+
MOCK_BALANCE_RESPONSE = {
|
|
22
|
+
"on_chain_balance": "125.500000",
|
|
23
|
+
"credits_balance": "45.00",
|
|
24
|
+
"currency": "USDC",
|
|
25
|
+
"address": TEST_ADDRESS,
|
|
26
|
+
"chain_id": 8453,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestGetBalance:
|
|
31
|
+
"""Synchronous get_balance() tests."""
|
|
32
|
+
|
|
33
|
+
def test_calls_correct_endpoint(self) -> None:
|
|
34
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
35
|
+
|
|
36
|
+
with patch.object(client, "call_free_get", return_value=MOCK_BALANCE_RESPONSE) as mock:
|
|
37
|
+
result = client.get_balance()
|
|
38
|
+
|
|
39
|
+
mock.assert_called_once_with("/v1/tools/balance")
|
|
40
|
+
assert result == MOCK_BALANCE_RESPONSE
|
|
41
|
+
|
|
42
|
+
def test_returns_all_fields(self) -> None:
|
|
43
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
44
|
+
|
|
45
|
+
with patch.object(client, "call_free_get", return_value=MOCK_BALANCE_RESPONSE):
|
|
46
|
+
result = client.get_balance()
|
|
47
|
+
|
|
48
|
+
assert result["on_chain_balance"] == "125.500000"
|
|
49
|
+
assert result["credits_balance"] == "45.00"
|
|
50
|
+
assert result["currency"] == "USDC"
|
|
51
|
+
assert result["address"] == TEST_ADDRESS
|
|
52
|
+
assert result["chain_id"] == 8453
|
|
53
|
+
|
|
54
|
+
def test_propagates_errors(self) -> None:
|
|
55
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
56
|
+
|
|
57
|
+
with patch.object(client, "call_free_get", side_effect=Exception("Network error")):
|
|
58
|
+
with pytest.raises(Exception, match="Network error"):
|
|
59
|
+
client.get_balance()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestAgetBalance:
|
|
63
|
+
"""Async aget_balance() tests."""
|
|
64
|
+
|
|
65
|
+
@pytest.mark.asyncio
|
|
66
|
+
async def test_calls_correct_endpoint(self) -> None:
|
|
67
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
68
|
+
|
|
69
|
+
with patch.object(
|
|
70
|
+
client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_BALANCE_RESPONSE
|
|
71
|
+
) as mock:
|
|
72
|
+
result = await client.aget_balance()
|
|
73
|
+
|
|
74
|
+
mock.assert_called_once_with("/v1/tools/balance")
|
|
75
|
+
assert result == MOCK_BALANCE_RESPONSE
|
|
76
|
+
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_returns_all_fields(self) -> None:
|
|
79
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
80
|
+
|
|
81
|
+
with patch.object(
|
|
82
|
+
client, "acall_free_get", new_callable=AsyncMock, return_value=MOCK_BALANCE_RESPONSE
|
|
83
|
+
):
|
|
84
|
+
result = await client.aget_balance()
|
|
85
|
+
|
|
86
|
+
assert result["on_chain_balance"] == "125.500000"
|
|
87
|
+
assert result["credits_balance"] == "45.00"
|
|
88
|
+
assert result["currency"] == "USDC"
|
|
89
|
+
|
|
90
|
+
@pytest.mark.asyncio
|
|
91
|
+
async def test_propagates_errors(self) -> None:
|
|
92
|
+
client = OneShotClient(TEST_PRIVATE_KEY)
|
|
93
|
+
|
|
94
|
+
with patch.object(
|
|
95
|
+
client, "acall_free_get", new_callable=AsyncMock, side_effect=Exception("Timeout")
|
|
96
|
+
):
|
|
97
|
+
with pytest.raises(Exception, match="Timeout"):
|
|
98
|
+
await client.aget_balance()
|
|
@@ -65,22 +65,25 @@ class TestParsePaymentRequired:
|
|
|
65
65
|
header_data = {
|
|
66
66
|
"x402Version": 2,
|
|
67
67
|
"accepts": [MOCK_ACCEPTED],
|
|
68
|
-
"resource": {},
|
|
68
|
+
"resource": {"url": "https://example.com/api"},
|
|
69
|
+
"extensions": {"bazaar": {"info": {}}},
|
|
69
70
|
}
|
|
70
71
|
encoded = base64.b64encode(json.dumps(header_data).encode()).decode()
|
|
71
72
|
result = parse_payment_required(encoded)
|
|
72
|
-
assert result["scheme"] == "exact"
|
|
73
|
-
assert result["network"] == "eip155:8453"
|
|
74
|
-
assert result["payTo"] == TO_ADDRESS
|
|
73
|
+
assert result["accepted"]["scheme"] == "exact"
|
|
74
|
+
assert result["accepted"]["network"] == "eip155:8453"
|
|
75
|
+
assert result["accepted"]["payTo"] == TO_ADDRESS
|
|
76
|
+
assert result["resource"]["url"] == "https://example.com/api"
|
|
77
|
+
assert "bazaar" in result["extensions"]
|
|
75
78
|
|
|
76
79
|
def test_returns_fallback_on_none(self) -> None:
|
|
77
80
|
result = parse_payment_required(None)
|
|
78
|
-
assert result["scheme"] == "exact"
|
|
79
|
-
assert result["extra"]["name"] == "USD Coin"
|
|
81
|
+
assert result["accepted"]["scheme"] == "exact"
|
|
82
|
+
assert result["accepted"]["extra"]["name"] == "USD Coin"
|
|
80
83
|
|
|
81
84
|
def test_returns_fallback_on_invalid_base64(self) -> None:
|
|
82
85
|
result = parse_payment_required("not-valid-base64!!!")
|
|
83
|
-
assert result["scheme"] == "exact"
|
|
86
|
+
assert result["accepted"]["scheme"] == "exact"
|
|
84
87
|
|
|
85
88
|
|
|
86
89
|
class TestSignPaymentAuthorization:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|