hostpay 0.2.0__tar.gz → 0.3.0__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.
- {hostpay-0.2.0 → hostpay-0.3.0}/PKG-INFO +23 -5
- {hostpay-0.2.0 → hostpay-0.3.0}/README.md +22 -4
- {hostpay-0.2.0 → hostpay-0.3.0}/hostpay/__init__.py +3 -2
- hostpay-0.3.0/hostpay/_async_resources.py +247 -0
- hostpay-0.3.0/hostpay/_client.py +250 -0
- {hostpay-0.2.0 → hostpay-0.3.0}/hostpay/resources.py +4 -0
- {hostpay-0.2.0 → hostpay-0.3.0}/pyproject.toml +1 -1
- hostpay-0.3.0/tests/test_async_client.py +153 -0
- {hostpay-0.2.0 → hostpay-0.3.0}/tests/test_client.py +15 -0
- hostpay-0.2.0/hostpay/_client.py +0 -138
- {hostpay-0.2.0 → hostpay-0.3.0}/.gitignore +0 -0
- {hostpay-0.2.0 → hostpay-0.3.0}/LICENSE +0 -0
- {hostpay-0.2.0 → hostpay-0.3.0}/hostpay/_object.py +0 -0
- {hostpay-0.2.0 → hostpay-0.3.0}/hostpay/errors.py +0 -0
- {hostpay-0.2.0 → hostpay-0.3.0}/hostpay/models.py +0 -0
- {hostpay-0.2.0 → hostpay-0.3.0}/hostpay/py.typed +0 -0
- {hostpay-0.2.0 → hostpay-0.3.0}/hostpay/webhooks.py +0 -0
- {hostpay-0.2.0 → hostpay-0.3.0}/tests/test_webhook_signature.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hostpay
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python SDK for the HostPay payments API
|
|
5
5
|
Project-URL: Homepage, https://hpay.host-sl.com
|
|
6
6
|
Project-URL: Documentation, https://hpay.host-sl.com/docs
|
|
@@ -31,14 +31,12 @@ Description-Content-Type: text/markdown
|
|
|
31
31
|
# HostPay Python SDK
|
|
32
32
|
|
|
33
33
|
A small, typed client for the [HostPay](https://hpay.host-sl.com) payments API —
|
|
34
|
-
wallets, deposits, transfers, payouts, escrow, transaction queries, user/wallet lifecycle management, and webhook verification.
|
|
34
|
+
wallets, deposits, transfers, payouts, escrow, transaction queries, user/wallet lifecycle management, and webhook verification. Ships both a sync (`HostPay`) and an async (`AsyncHostPay`) client.
|
|
35
35
|
|
|
36
36
|
## Install
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
pip install hostpay
|
|
40
|
-
# or, from this repo:
|
|
41
|
-
pip install ./sdk/python
|
|
39
|
+
pip install hostpay
|
|
42
40
|
```
|
|
43
41
|
|
|
44
42
|
Requires Python 3.8+ and `httpx`.
|
|
@@ -74,6 +72,26 @@ hold = client.escrow.hold(wallet_id=wallet.id, amount=10)
|
|
|
74
72
|
client.escrow.release(hold.id, recipient_wallet_id="...")
|
|
75
73
|
```
|
|
76
74
|
|
|
75
|
+
## Async
|
|
76
|
+
|
|
77
|
+
`AsyncHostPay` exposes the exact same surface — every method awaited, built on
|
|
78
|
+
`httpx.AsyncClient`. Use it from FastAPI, aiohttp, or any asyncio app:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from hostpay import AsyncHostPay
|
|
82
|
+
|
|
83
|
+
async with AsyncHostPay(api_key="ak-...", secret_key="sk-...") as client:
|
|
84
|
+
user = await client.users.create(
|
|
85
|
+
app_user_id="user_123", name="Alice", phone_number="+23279000000"
|
|
86
|
+
)
|
|
87
|
+
wallet = await client.wallets.create(user.id)
|
|
88
|
+
await client.deposits.mobile_money(wallet_id=wallet.id, amount=100)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Outside a context manager, call `await client.aclose()` when done. Webhook
|
|
92
|
+
verification (`client.webhooks.construct_event`) is pure crypto with no I/O,
|
|
93
|
+
so it stays a plain synchronous call on both clients.
|
|
94
|
+
|
|
77
95
|
## Authentication
|
|
78
96
|
|
|
79
97
|
Pass your `api-key` and `secret-key` once at construction; they're sent on every
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
# HostPay Python SDK
|
|
2
2
|
|
|
3
3
|
A small, typed client for the [HostPay](https://hpay.host-sl.com) payments API —
|
|
4
|
-
wallets, deposits, transfers, payouts, escrow, transaction queries, user/wallet lifecycle management, and webhook verification.
|
|
4
|
+
wallets, deposits, transfers, payouts, escrow, transaction queries, user/wallet lifecycle management, and webhook verification. Ships both a sync (`HostPay`) and an async (`AsyncHostPay`) client.
|
|
5
5
|
|
|
6
6
|
## Install
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
|
-
pip install hostpay
|
|
10
|
-
# or, from this repo:
|
|
11
|
-
pip install ./sdk/python
|
|
9
|
+
pip install hostpay
|
|
12
10
|
```
|
|
13
11
|
|
|
14
12
|
Requires Python 3.8+ and `httpx`.
|
|
@@ -44,6 +42,26 @@ hold = client.escrow.hold(wallet_id=wallet.id, amount=10)
|
|
|
44
42
|
client.escrow.release(hold.id, recipient_wallet_id="...")
|
|
45
43
|
```
|
|
46
44
|
|
|
45
|
+
## Async
|
|
46
|
+
|
|
47
|
+
`AsyncHostPay` exposes the exact same surface — every method awaited, built on
|
|
48
|
+
`httpx.AsyncClient`. Use it from FastAPI, aiohttp, or any asyncio app:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from hostpay import AsyncHostPay
|
|
52
|
+
|
|
53
|
+
async with AsyncHostPay(api_key="ak-...", secret_key="sk-...") as client:
|
|
54
|
+
user = await client.users.create(
|
|
55
|
+
app_user_id="user_123", name="Alice", phone_number="+23279000000"
|
|
56
|
+
)
|
|
57
|
+
wallet = await client.wallets.create(user.id)
|
|
58
|
+
await client.deposits.mobile_money(wallet_id=wallet.id, amount=100)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Outside a context manager, call `await client.aclose()` when done. Webhook
|
|
62
|
+
verification (`client.webhooks.construct_event`) is pure crypto with no I/O,
|
|
63
|
+
so it stays a plain synchronous call on both clients.
|
|
64
|
+
|
|
47
65
|
## Authentication
|
|
48
66
|
|
|
49
67
|
Pass your `api-key` and `secret-key` once at construction; they're sent on every
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""HostPay Python SDK."""
|
|
2
|
-
from ._client import HostPay
|
|
2
|
+
from ._client import AsyncHostPay, HostPay
|
|
3
3
|
from ._object import HostPayObject
|
|
4
4
|
from .errors import (
|
|
5
5
|
APIConnectionError,
|
|
@@ -12,10 +12,11 @@ from .errors import (
|
|
|
12
12
|
)
|
|
13
13
|
from .models import EscrowResponse, TransactionResponse, UserRead, WalletRead
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
from ._client import _VERSION as __version__
|
|
16
16
|
|
|
17
17
|
__all__ = [
|
|
18
18
|
"HostPay",
|
|
19
|
+
"AsyncHostPay",
|
|
19
20
|
"HostPayObject",
|
|
20
21
|
"HostPayError",
|
|
21
22
|
"AuthenticationError",
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Async twins of the resource groups in resources.py.
|
|
2
|
+
|
|
3
|
+
Kept as an explicit mirror (rather than sharing the sync classes) so that
|
|
4
|
+
`await client.users.create(...)` type-checks: an async def returning UserRead
|
|
5
|
+
is awaitable, a sync method annotated UserRead is not. A parity test asserts
|
|
6
|
+
the two modules expose identical classes, methods, and signatures.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from .models import EscrowResponse, TransactionResponse, UserRead, WalletRead
|
|
13
|
+
from .resources import PROVIDER_AFRICELL, PROVIDER_ORANGE # noqa: F401 (re-export)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Resource:
|
|
17
|
+
def __init__(self, transport: Any) -> None:
|
|
18
|
+
self._t = transport
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Users(_Resource):
|
|
22
|
+
async def create(
|
|
23
|
+
self,
|
|
24
|
+
app_user_id: str,
|
|
25
|
+
name: str,
|
|
26
|
+
phone_number: str,
|
|
27
|
+
email: Optional[str] = None,
|
|
28
|
+
username: Optional[str] = None,
|
|
29
|
+
) -> UserRead:
|
|
30
|
+
return await self._t.request("POST", "/api/v1/users/create/", json={
|
|
31
|
+
"app_user_id": app_user_id,
|
|
32
|
+
"name": name,
|
|
33
|
+
"phone_number": phone_number,
|
|
34
|
+
"email": email,
|
|
35
|
+
"username": username,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
async def get(self, user_id: str) -> UserRead:
|
|
39
|
+
return await self._t.request("GET", f"/api/v1/users/{user_id}/")
|
|
40
|
+
|
|
41
|
+
async def list(self, is_active: Optional[bool] = None) -> list[UserRead]:
|
|
42
|
+
params = {} if is_active is None else {"is_active": is_active}
|
|
43
|
+
return await self._t.request("GET", "/api/v1/users/", params=params)
|
|
44
|
+
|
|
45
|
+
async def update(
|
|
46
|
+
self,
|
|
47
|
+
user_id: str,
|
|
48
|
+
app_user_id: str,
|
|
49
|
+
name: str,
|
|
50
|
+
phone_number: str,
|
|
51
|
+
email: Optional[str] = None,
|
|
52
|
+
username: Optional[str] = None,
|
|
53
|
+
) -> UserRead:
|
|
54
|
+
"""Full update — the API expects the complete user body; app_user_id
|
|
55
|
+
must match the existing value (it is immutable)."""
|
|
56
|
+
return await self._t.request("PUT", f"/api/v1/users/{user_id}/", json={
|
|
57
|
+
"app_user_id": app_user_id,
|
|
58
|
+
"name": name,
|
|
59
|
+
"phone_number": phone_number,
|
|
60
|
+
"email": email,
|
|
61
|
+
"username": username,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
async def delete(self, user_id: str) -> Any:
|
|
65
|
+
return await self._t.request("DELETE", f"/api/v1/users/{user_id}/")
|
|
66
|
+
|
|
67
|
+
async def disable(self, user_id: str) -> Any:
|
|
68
|
+
return await self._t.request("POST", f"/api/v1/users/{user_id}/disable")
|
|
69
|
+
|
|
70
|
+
async def enable(self, user_id: str) -> Any:
|
|
71
|
+
return await self._t.request("POST", f"/api/v1/users/{user_id}/enable")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Wallets(_Resource):
|
|
75
|
+
async def create(self, user_id: str) -> WalletRead:
|
|
76
|
+
return await self._t.request("POST", f"/api/v1/wallets/create/{user_id}/")
|
|
77
|
+
|
|
78
|
+
async def get(self, user_id: str) -> WalletRead:
|
|
79
|
+
return await self._t.request("GET", f"/api/v1/wallets/{user_id}/")
|
|
80
|
+
|
|
81
|
+
async def balance(self, wallet_id: str) -> Any:
|
|
82
|
+
return await self._t.request("GET", f"/api/v1/wallets/{wallet_id}/balance")
|
|
83
|
+
|
|
84
|
+
async def list(self, is_active: Optional[bool] = None) -> list[WalletRead]:
|
|
85
|
+
params = {} if is_active is None else {"is_active": is_active}
|
|
86
|
+
return await self._t.request("GET", "/api/v1/wallets/", params=params)
|
|
87
|
+
|
|
88
|
+
async def disable(self, wallet_id: str) -> Any:
|
|
89
|
+
return await self._t.request("POST", f"/api/v1/wallets/{wallet_id}/disable")
|
|
90
|
+
|
|
91
|
+
async def enable(self, wallet_id: str) -> Any:
|
|
92
|
+
return await self._t.request("POST", f"/api/v1/wallets/{wallet_id}/enable")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Transactions(_Resource):
|
|
96
|
+
async def get(self, transaction_id: str) -> TransactionResponse:
|
|
97
|
+
return await self._t.request("GET", f"/api/v1/transactions/{transaction_id}")
|
|
98
|
+
|
|
99
|
+
async def list(
|
|
100
|
+
self,
|
|
101
|
+
status: Optional[str] = None,
|
|
102
|
+
transaction_type: Optional[str] = None,
|
|
103
|
+
start_date: Optional[str] = None,
|
|
104
|
+
end_date: Optional[str] = None,
|
|
105
|
+
search: Optional[str] = None,
|
|
106
|
+
limit: int = 100,
|
|
107
|
+
offset: int = 0,
|
|
108
|
+
) -> list[TransactionResponse]:
|
|
109
|
+
params = {
|
|
110
|
+
"status": status,
|
|
111
|
+
"transaction_type": transaction_type,
|
|
112
|
+
"start_date": start_date,
|
|
113
|
+
"end_date": end_date,
|
|
114
|
+
"search": search,
|
|
115
|
+
"limit": limit,
|
|
116
|
+
"offset": offset,
|
|
117
|
+
}
|
|
118
|
+
return await self._t.request(
|
|
119
|
+
"GET",
|
|
120
|
+
"/api/v1/transactions/",
|
|
121
|
+
params={k: v for k, v in params.items() if v is not None},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def for_wallet(self, wallet_id: str) -> list[TransactionResponse]:
|
|
125
|
+
"""All transactions for a wallet, incoming and outgoing."""
|
|
126
|
+
return await self._t.request("GET", f"/api/v1/transactions/wallet/{wallet_id}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Deposits(_Resource):
|
|
130
|
+
async def mobile_money(
|
|
131
|
+
self, wallet_id: str, amount: int, idempotency_key: Optional[str] = None
|
|
132
|
+
) -> Any:
|
|
133
|
+
return await self._t.request(
|
|
134
|
+
"POST",
|
|
135
|
+
"/api/v1/transactions/wallet/mobile-money-deposit",
|
|
136
|
+
json={"wallet_id": wallet_id, "amount": amount},
|
|
137
|
+
idempotency_key=idempotency_key,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
async def card(
|
|
141
|
+
self,
|
|
142
|
+
wallet_id: str,
|
|
143
|
+
amount: float,
|
|
144
|
+
payment_method_id: Optional[str] = None,
|
|
145
|
+
idempotency_key: Optional[str] = None,
|
|
146
|
+
) -> Any:
|
|
147
|
+
return await self._t.request(
|
|
148
|
+
"POST",
|
|
149
|
+
"/api/v1/transactions/wallet/card-deposit/create",
|
|
150
|
+
json={
|
|
151
|
+
"wallet_id": wallet_id,
|
|
152
|
+
"amount": amount,
|
|
153
|
+
"payment_method_id": payment_method_id,
|
|
154
|
+
},
|
|
155
|
+
idempotency_key=idempotency_key,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Transfers(_Resource):
|
|
160
|
+
async def create(
|
|
161
|
+
self,
|
|
162
|
+
sender_wallet_id: str,
|
|
163
|
+
recipient_identifier: str,
|
|
164
|
+
amount: float,
|
|
165
|
+
description: Optional[str] = None,
|
|
166
|
+
idempotency_key: Optional[str] = None,
|
|
167
|
+
) -> TransactionResponse:
|
|
168
|
+
return await self._t.request(
|
|
169
|
+
"POST",
|
|
170
|
+
"/api/v1/transactions/wallet/transfer/",
|
|
171
|
+
json={
|
|
172
|
+
"sender_wallet_id": sender_wallet_id,
|
|
173
|
+
"recipient_identifier": recipient_identifier,
|
|
174
|
+
"amount": amount,
|
|
175
|
+
"description": description,
|
|
176
|
+
},
|
|
177
|
+
idempotency_key=idempotency_key,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class Payouts(_Resource):
|
|
182
|
+
async def mobile_money(
|
|
183
|
+
self,
|
|
184
|
+
wallet_id: str,
|
|
185
|
+
amount: float,
|
|
186
|
+
phone_number: str,
|
|
187
|
+
provider: str = PROVIDER_ORANGE,
|
|
188
|
+
currency: str = "SLE",
|
|
189
|
+
idempotency_key: Optional[str] = None,
|
|
190
|
+
) -> TransactionResponse:
|
|
191
|
+
return await self._t.request(
|
|
192
|
+
"POST",
|
|
193
|
+
"/api/v1/transactions/wallet/mobile-money-cashout/",
|
|
194
|
+
json={
|
|
195
|
+
"wallet_id": wallet_id,
|
|
196
|
+
"amount": amount,
|
|
197
|
+
"phone_number": phone_number,
|
|
198
|
+
"provider": provider,
|
|
199
|
+
"currency": currency,
|
|
200
|
+
},
|
|
201
|
+
idempotency_key=idempotency_key,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
async def bank(
|
|
205
|
+
self,
|
|
206
|
+
wallet_id: str,
|
|
207
|
+
amount: float,
|
|
208
|
+
currency: str = "usd",
|
|
209
|
+
description: Optional[str] = None,
|
|
210
|
+
idempotency_key: Optional[str] = None,
|
|
211
|
+
) -> TransactionResponse:
|
|
212
|
+
return await self._t.request(
|
|
213
|
+
"POST",
|
|
214
|
+
"/api/v1/transactions/wallet/payout/",
|
|
215
|
+
json={
|
|
216
|
+
"wallet_id": wallet_id,
|
|
217
|
+
"amount": amount,
|
|
218
|
+
"currency": currency,
|
|
219
|
+
"description": description,
|
|
220
|
+
},
|
|
221
|
+
idempotency_key=idempotency_key,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class Escrow(_Resource):
|
|
226
|
+
async def hold(
|
|
227
|
+
self, wallet_id: str, amount: float, description: Optional[str] = None
|
|
228
|
+
) -> EscrowResponse:
|
|
229
|
+
return await self._t.request("POST", "/api/v1/escrow/hold", json={
|
|
230
|
+
"wallet_id": wallet_id,
|
|
231
|
+
"amount": amount,
|
|
232
|
+
"description": description,
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
async def release(
|
|
236
|
+
self, transaction_id: str, recipient_wallet_id: str, amount: Optional[float] = None
|
|
237
|
+
) -> EscrowResponse:
|
|
238
|
+
return await self._t.request(
|
|
239
|
+
"POST",
|
|
240
|
+
f"/api/v1/escrow/{transaction_id}/release",
|
|
241
|
+
json={"recipient_wallet_id": recipient_wallet_id, "amount": amount},
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
async def refund(self, transaction_id: str, amount: Optional[float] = None) -> EscrowResponse:
|
|
245
|
+
return await self._t.request(
|
|
246
|
+
"POST", f"/api/v1/escrow/{transaction_id}/refund", json={"amount": amount}
|
|
247
|
+
)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""HTTP transport + the top-level HostPay clients (sync and async)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import time
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from . import _async_resources
|
|
12
|
+
from ._object import HostPayObject, _wrap
|
|
13
|
+
from .errors import APIConnectionError, error_from_status
|
|
14
|
+
from .resources import (
|
|
15
|
+
Deposits,
|
|
16
|
+
Escrow,
|
|
17
|
+
Payouts,
|
|
18
|
+
Transactions,
|
|
19
|
+
Transfers,
|
|
20
|
+
Users,
|
|
21
|
+
Wallets,
|
|
22
|
+
)
|
|
23
|
+
from .webhooks import Webhooks
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://hpay-api.host-sl.com"
|
|
26
|
+
|
|
27
|
+
# Single-sourced from pyproject.toml via the installed package metadata, so it
|
|
28
|
+
# can never drift from the released version again.
|
|
29
|
+
try:
|
|
30
|
+
_VERSION = _pkg_version("hostpay")
|
|
31
|
+
except PackageNotFoundError: # uninstalled source checkout
|
|
32
|
+
_VERSION = "0.0.0.dev0"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _Transport:
|
|
36
|
+
"""Builds authenticated requests, retries transient failures, maps errors."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
api_key: str,
|
|
41
|
+
secret_key: str,
|
|
42
|
+
base_url: str,
|
|
43
|
+
timeout: float,
|
|
44
|
+
max_retries: int,
|
|
45
|
+
http_client: Optional[httpx.Client],
|
|
46
|
+
) -> None:
|
|
47
|
+
self._max_retries = max_retries
|
|
48
|
+
# Auth is applied per-request so a caller-supplied http_client is still
|
|
49
|
+
# authenticated.
|
|
50
|
+
self._auth = {
|
|
51
|
+
"api-key": api_key,
|
|
52
|
+
"secret-key": secret_key,
|
|
53
|
+
"User-Agent": f"hostpay-python/{_VERSION}",
|
|
54
|
+
}
|
|
55
|
+
self._client = http_client or httpx.Client(
|
|
56
|
+
base_url=base_url.rstrip("/"), timeout=timeout
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def request(
|
|
60
|
+
self,
|
|
61
|
+
method: str,
|
|
62
|
+
path: str,
|
|
63
|
+
json: Optional[dict] = None,
|
|
64
|
+
params: Optional[dict] = None,
|
|
65
|
+
idempotency_key: Optional[str] = None,
|
|
66
|
+
) -> Any:
|
|
67
|
+
headers: Dict[str, str] = dict(self._auth)
|
|
68
|
+
if idempotency_key:
|
|
69
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
70
|
+
# Retry only idempotent-safe conditions: connection errors and 5xx. POSTs
|
|
71
|
+
# are retried too — the API supports Idempotency-Key for money movements.
|
|
72
|
+
last_exc: Optional[Exception] = None
|
|
73
|
+
for attempt in range(self._max_retries + 1):
|
|
74
|
+
try:
|
|
75
|
+
resp = self._client.request(
|
|
76
|
+
method, path, json=json, params=params, headers=headers
|
|
77
|
+
)
|
|
78
|
+
except httpx.HTTPError as exc:
|
|
79
|
+
last_exc = exc
|
|
80
|
+
if attempt < self._max_retries:
|
|
81
|
+
time.sleep(0.5 * (2 ** attempt))
|
|
82
|
+
continue
|
|
83
|
+
raise APIConnectionError(f"Could not reach HostPay: {exc}") from exc
|
|
84
|
+
|
|
85
|
+
if resp.status_code >= 500 and attempt < self._max_retries:
|
|
86
|
+
time.sleep(0.5 * (2 ** attempt))
|
|
87
|
+
continue
|
|
88
|
+
return _handle_response(resp)
|
|
89
|
+
raise APIConnectionError(f"Could not reach HostPay: {last_exc}")
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
self._client.close()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _handle_response(resp: "httpx.Response") -> Any:
|
|
96
|
+
if 200 <= resp.status_code < 300:
|
|
97
|
+
if not resp.content:
|
|
98
|
+
return None
|
|
99
|
+
return _wrap(resp.json())
|
|
100
|
+
try:
|
|
101
|
+
body = resp.json()
|
|
102
|
+
detail = body.get("detail", body) if isinstance(body, dict) else body
|
|
103
|
+
except ValueError:
|
|
104
|
+
detail = resp.text
|
|
105
|
+
message = detail if isinstance(detail, str) else f"HTTP {resp.status_code}"
|
|
106
|
+
raise error_from_status(resp.status_code, message, detail)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class HostPay:
|
|
110
|
+
"""HostPay API client.
|
|
111
|
+
|
|
112
|
+
>>> client = HostPay(api_key="ak-...", secret_key="sk-...")
|
|
113
|
+
>>> user = client.users.create(app_user_id="u1", name="Alice", phone_number="+23279000000")
|
|
114
|
+
>>> wallet = client.wallets.create(user.id)
|
|
115
|
+
>>> client.deposits.mobile_money(wallet_id=wallet.id, amount=100)
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
api_key: str,
|
|
121
|
+
secret_key: str,
|
|
122
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
123
|
+
timeout: float = 30.0,
|
|
124
|
+
max_retries: int = 2,
|
|
125
|
+
http_client: Optional[httpx.Client] = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
if not api_key or not secret_key:
|
|
128
|
+
raise ValueError("api_key and secret_key are required")
|
|
129
|
+
self._transport = _Transport(
|
|
130
|
+
api_key, secret_key, base_url, timeout, max_retries, http_client
|
|
131
|
+
)
|
|
132
|
+
self.users = Users(self._transport)
|
|
133
|
+
self.wallets = Wallets(self._transport)
|
|
134
|
+
self.deposits = Deposits(self._transport)
|
|
135
|
+
self.transfers = Transfers(self._transport)
|
|
136
|
+
self.transactions = Transactions(self._transport)
|
|
137
|
+
self.payouts = Payouts(self._transport)
|
|
138
|
+
self.escrow = Escrow(self._transport)
|
|
139
|
+
self.webhooks = Webhooks()
|
|
140
|
+
|
|
141
|
+
def close(self) -> None:
|
|
142
|
+
self._transport.close()
|
|
143
|
+
|
|
144
|
+
def __enter__(self) -> "HostPay":
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def __exit__(self, *exc: object) -> None:
|
|
148
|
+
self.close()
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class _AsyncTransport:
|
|
152
|
+
"""Async twin of _Transport: same auth, retries, and error mapping."""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
api_key: str,
|
|
157
|
+
secret_key: str,
|
|
158
|
+
base_url: str,
|
|
159
|
+
timeout: float,
|
|
160
|
+
max_retries: int,
|
|
161
|
+
http_client: Optional[httpx.AsyncClient],
|
|
162
|
+
) -> None:
|
|
163
|
+
self._max_retries = max_retries
|
|
164
|
+
# Auth is applied per-request so a caller-supplied http_client is still
|
|
165
|
+
# authenticated.
|
|
166
|
+
self._auth = {
|
|
167
|
+
"api-key": api_key,
|
|
168
|
+
"secret-key": secret_key,
|
|
169
|
+
"User-Agent": f"hostpay-python/{_VERSION}",
|
|
170
|
+
}
|
|
171
|
+
self._client = http_client or httpx.AsyncClient(
|
|
172
|
+
base_url=base_url.rstrip("/"), timeout=timeout
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
async def request(
|
|
176
|
+
self,
|
|
177
|
+
method: str,
|
|
178
|
+
path: str,
|
|
179
|
+
json: Optional[dict] = None,
|
|
180
|
+
params: Optional[dict] = None,
|
|
181
|
+
idempotency_key: Optional[str] = None,
|
|
182
|
+
) -> Any:
|
|
183
|
+
headers: Dict[str, str] = dict(self._auth)
|
|
184
|
+
if idempotency_key:
|
|
185
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
186
|
+
# Retry only idempotent-safe conditions: connection errors and 5xx. POSTs
|
|
187
|
+
# are retried too — the API supports Idempotency-Key for money movements.
|
|
188
|
+
last_exc: Optional[Exception] = None
|
|
189
|
+
for attempt in range(self._max_retries + 1):
|
|
190
|
+
try:
|
|
191
|
+
resp = await self._client.request(
|
|
192
|
+
method, path, json=json, params=params, headers=headers
|
|
193
|
+
)
|
|
194
|
+
except httpx.HTTPError as exc:
|
|
195
|
+
last_exc = exc
|
|
196
|
+
if attempt < self._max_retries:
|
|
197
|
+
await asyncio.sleep(0.5 * (2 ** attempt))
|
|
198
|
+
continue
|
|
199
|
+
raise APIConnectionError(f"Could not reach HostPay: {exc}") from exc
|
|
200
|
+
|
|
201
|
+
if resp.status_code >= 500 and attempt < self._max_retries:
|
|
202
|
+
await asyncio.sleep(0.5 * (2 ** attempt))
|
|
203
|
+
continue
|
|
204
|
+
return _handle_response(resp)
|
|
205
|
+
raise APIConnectionError(f"Could not reach HostPay: {last_exc}")
|
|
206
|
+
|
|
207
|
+
async def close(self) -> None:
|
|
208
|
+
await self._client.aclose()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class AsyncHostPay:
|
|
212
|
+
"""Async HostPay API client — the same surface as HostPay, awaited.
|
|
213
|
+
|
|
214
|
+
>>> async with AsyncHostPay(api_key="ak-...", secret_key="sk-...") as client:
|
|
215
|
+
... user = await client.users.create(app_user_id="u1", name="Alice", phone_number="+23279000000")
|
|
216
|
+
... wallet = await client.wallets.create(user.id)
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
api_key: str,
|
|
222
|
+
secret_key: str,
|
|
223
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
224
|
+
timeout: float = 30.0,
|
|
225
|
+
max_retries: int = 2,
|
|
226
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
if not api_key or not secret_key:
|
|
229
|
+
raise ValueError("api_key and secret_key are required")
|
|
230
|
+
self._transport = _AsyncTransport(
|
|
231
|
+
api_key, secret_key, base_url, timeout, max_retries, http_client
|
|
232
|
+
)
|
|
233
|
+
self.users = _async_resources.Users(self._transport)
|
|
234
|
+
self.wallets = _async_resources.Wallets(self._transport)
|
|
235
|
+
self.deposits = _async_resources.Deposits(self._transport)
|
|
236
|
+
self.transfers = _async_resources.Transfers(self._transport)
|
|
237
|
+
self.transactions = _async_resources.Transactions(self._transport)
|
|
238
|
+
self.payouts = _async_resources.Payouts(self._transport)
|
|
239
|
+
self.escrow = _async_resources.Escrow(self._transport)
|
|
240
|
+
# Webhook verification is pure crypto (no I/O) — shared with the sync client.
|
|
241
|
+
self.webhooks = Webhooks()
|
|
242
|
+
|
|
243
|
+
async def aclose(self) -> None:
|
|
244
|
+
await self._transport.close()
|
|
245
|
+
|
|
246
|
+
async def __aenter__(self) -> "AsyncHostPay":
|
|
247
|
+
return self
|
|
248
|
+
|
|
249
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
250
|
+
await self.aclose()
|
|
@@ -81,6 +81,10 @@ class Wallets(_Resource):
|
|
|
81
81
|
def balance(self, wallet_id: str) -> Any:
|
|
82
82
|
return self._t.request("GET", f"/api/v1/wallets/{wallet_id}/balance")
|
|
83
83
|
|
|
84
|
+
def list(self, is_active: Optional[bool] = None) -> list[WalletRead]:
|
|
85
|
+
params = {} if is_active is None else {"is_active": is_active}
|
|
86
|
+
return self._t.request("GET", "/api/v1/wallets/", params=params)
|
|
87
|
+
|
|
84
88
|
def disable(self, wallet_id: str) -> Any:
|
|
85
89
|
return self._t.request("POST", f"/api/v1/wallets/{wallet_id}/disable")
|
|
86
90
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Async client tests — mirrors test_client.py through AsyncHostPay.
|
|
2
|
+
|
|
3
|
+
Runs each case with asyncio.run() inside a plain sync test, so no
|
|
4
|
+
pytest-asyncio dependency (works unchanged on Python 3.8–3.14).
|
|
5
|
+
"""
|
|
6
|
+
import asyncio
|
|
7
|
+
import inspect
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from hostpay import AsyncHostPay, AuthenticationError, InvalidRequestError
|
|
14
|
+
from hostpay import _async_resources, resources
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _client(handler):
|
|
18
|
+
"""An AsyncHostPay client whose HTTP layer is a MockTransport running `handler`."""
|
|
19
|
+
mock = httpx.MockTransport(handler)
|
|
20
|
+
http = httpx.AsyncClient(base_url="https://api.test", transport=mock)
|
|
21
|
+
return AsyncHostPay(api_key="ak-x", secret_key="sk-y", http_client=http)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_auth_headers_and_path_and_body():
|
|
25
|
+
seen = {}
|
|
26
|
+
|
|
27
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
28
|
+
seen["path"] = request.url.path
|
|
29
|
+
seen["api-key"] = request.headers.get("api-key")
|
|
30
|
+
seen["secret-key"] = request.headers.get("secret-key")
|
|
31
|
+
seen["body"] = json.loads(request.content)
|
|
32
|
+
return httpx.Response(201, json={"id": "usr_1", "name": "Alice"})
|
|
33
|
+
|
|
34
|
+
async def case():
|
|
35
|
+
async with _client(handler) as client:
|
|
36
|
+
return await client.users.create(
|
|
37
|
+
app_user_id="u1", name="Alice", phone_number="+23279000000"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
user = asyncio.run(case())
|
|
41
|
+
assert seen["path"] == "/api/v1/users/create/"
|
|
42
|
+
assert seen["api-key"] == "ak-x" and seen["secret-key"] == "sk-y"
|
|
43
|
+
assert seen["body"]["app_user_id"] == "u1"
|
|
44
|
+
assert user.id == "usr_1" # attribute access on the response
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_idempotency_key_forwarded():
|
|
48
|
+
seen = {}
|
|
49
|
+
|
|
50
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
51
|
+
seen["idem"] = request.headers.get("Idempotency-Key")
|
|
52
|
+
return httpx.Response(200, json={"transaction_id": "txn_1"})
|
|
53
|
+
|
|
54
|
+
async def case():
|
|
55
|
+
async with _client(handler) as client:
|
|
56
|
+
await client.deposits.mobile_money(
|
|
57
|
+
wallet_id="w1", amount=100, idempotency_key="abc-123"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
asyncio.run(case())
|
|
61
|
+
assert seen["idem"] == "abc-123"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_error_mapping():
|
|
65
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
66
|
+
return httpx.Response(403, json={"detail": "Invalid credentials"})
|
|
67
|
+
|
|
68
|
+
async def case():
|
|
69
|
+
async with _client(handler) as client:
|
|
70
|
+
await client.wallets.balance("w1")
|
|
71
|
+
|
|
72
|
+
with pytest.raises(AuthenticationError) as exc:
|
|
73
|
+
asyncio.run(case())
|
|
74
|
+
assert exc.value.status_code == 403
|
|
75
|
+
assert "Invalid credentials" in str(exc.value)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_validation_error_is_invalid_request():
|
|
79
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
80
|
+
return httpx.Response(422, json={"detail": "bad amount"})
|
|
81
|
+
|
|
82
|
+
async def case():
|
|
83
|
+
async with _client(handler) as client:
|
|
84
|
+
await client.payouts.mobile_money(
|
|
85
|
+
wallet_id="w1", amount=-5, phone_number="+232"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
with pytest.raises(InvalidRequestError):
|
|
89
|
+
asyncio.run(case())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_query_params_and_list_response():
|
|
93
|
+
seen = {}
|
|
94
|
+
|
|
95
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
96
|
+
seen["path"] = request.url.path
|
|
97
|
+
seen["query"] = dict(request.url.params)
|
|
98
|
+
return httpx.Response(200, json=[{"id": "usr_1"}])
|
|
99
|
+
|
|
100
|
+
async def case():
|
|
101
|
+
async with _client(handler) as client:
|
|
102
|
+
return await client.users.list(is_active=True)
|
|
103
|
+
|
|
104
|
+
users = asyncio.run(case())
|
|
105
|
+
assert seen["path"] == "/api/v1/users/"
|
|
106
|
+
assert seen["query"] == {"is_active": "true"}
|
|
107
|
+
assert users[0]["id"] == "usr_1"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_retries_5xx_then_succeeds():
|
|
111
|
+
calls = []
|
|
112
|
+
|
|
113
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
114
|
+
calls.append(1)
|
|
115
|
+
if len(calls) == 1:
|
|
116
|
+
return httpx.Response(502, json={"detail": "bad gateway"})
|
|
117
|
+
return httpx.Response(200, json={"balance": "5.00"})
|
|
118
|
+
|
|
119
|
+
async def case():
|
|
120
|
+
async with _client(handler) as client:
|
|
121
|
+
return await client.wallets.balance("w1")
|
|
122
|
+
|
|
123
|
+
bal = asyncio.run(case())
|
|
124
|
+
assert len(calls) == 2
|
|
125
|
+
assert bal["balance"] == "5.00"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_async_resources_mirror_sync():
|
|
129
|
+
"""The async module must expose the same classes, methods, and signatures
|
|
130
|
+
as the sync one — this is the guard that keeps the two copies in step."""
|
|
131
|
+
sync_classes = {
|
|
132
|
+
n: c
|
|
133
|
+
for n, c in vars(resources).items()
|
|
134
|
+
if inspect.isclass(c) and not n.startswith("_") and c.__module__ == resources.__name__
|
|
135
|
+
}
|
|
136
|
+
assert sync_classes, "no sync resource classes found"
|
|
137
|
+
for name, sync_cls in sync_classes.items():
|
|
138
|
+
async_cls = getattr(_async_resources, name, None)
|
|
139
|
+
assert async_cls is not None, f"missing async twin for {name}"
|
|
140
|
+
sync_methods = {
|
|
141
|
+
n: f for n, f in inspect.getmembers(sync_cls, inspect.isfunction)
|
|
142
|
+
if not n.startswith("_")
|
|
143
|
+
}
|
|
144
|
+
async_methods = {
|
|
145
|
+
n: f for n, f in inspect.getmembers(async_cls, inspect.isfunction)
|
|
146
|
+
if not n.startswith("_")
|
|
147
|
+
}
|
|
148
|
+
assert sync_methods.keys() == async_methods.keys(), name
|
|
149
|
+
for meth, sync_fn in sync_methods.items():
|
|
150
|
+
assert inspect.iscoroutinefunction(async_methods[meth]), f"{name}.{meth} not async"
|
|
151
|
+
assert inspect.signature(sync_fn) == inspect.signature(async_methods[meth]), (
|
|
152
|
+
f"{name}.{meth} signature drifted"
|
|
153
|
+
)
|
|
@@ -123,3 +123,18 @@ def test_lifecycle_and_transactions_paths():
|
|
|
123
123
|
("GET", "/api/v1/transactions/wallet/w1", {}),
|
|
124
124
|
("GET", "/api/v1/transactions/", {"status": "completed", "limit": "10", "offset": "0"}),
|
|
125
125
|
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_wallets_list_passes_query_params():
|
|
129
|
+
seen = {}
|
|
130
|
+
|
|
131
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
132
|
+
seen["path"] = request.url.path
|
|
133
|
+
seen["query"] = dict(request.url.params)
|
|
134
|
+
return httpx.Response(200, json=[{"id": "w1", "balance": "5.00"}])
|
|
135
|
+
|
|
136
|
+
client = _client(handler)
|
|
137
|
+
wallets = client.wallets.list(is_active=True)
|
|
138
|
+
assert seen["path"] == "/api/v1/wallets/"
|
|
139
|
+
assert seen["query"] == {"is_active": "true"}
|
|
140
|
+
assert wallets[0]["id"] == "w1"
|
hostpay-0.2.0/hostpay/_client.py
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
"""HTTP transport + the top-level HostPay client."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import time
|
|
5
|
-
from typing import Any, Dict, Optional
|
|
6
|
-
|
|
7
|
-
import httpx
|
|
8
|
-
|
|
9
|
-
from ._object import HostPayObject, _wrap
|
|
10
|
-
from .errors import APIConnectionError, error_from_status
|
|
11
|
-
from .resources import (
|
|
12
|
-
Deposits,
|
|
13
|
-
Escrow,
|
|
14
|
-
Payouts,
|
|
15
|
-
Transactions,
|
|
16
|
-
Transfers,
|
|
17
|
-
Users,
|
|
18
|
-
Wallets,
|
|
19
|
-
)
|
|
20
|
-
from .webhooks import Webhooks
|
|
21
|
-
|
|
22
|
-
DEFAULT_BASE_URL = "https://hpay-api.host-sl.com"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class _Transport:
|
|
26
|
-
"""Builds authenticated requests, retries transient failures, maps errors."""
|
|
27
|
-
|
|
28
|
-
def __init__(
|
|
29
|
-
self,
|
|
30
|
-
api_key: str,
|
|
31
|
-
secret_key: str,
|
|
32
|
-
base_url: str,
|
|
33
|
-
timeout: float,
|
|
34
|
-
max_retries: int,
|
|
35
|
-
http_client: Optional[httpx.Client],
|
|
36
|
-
) -> None:
|
|
37
|
-
self._max_retries = max_retries
|
|
38
|
-
# Auth is applied per-request so a caller-supplied http_client is still
|
|
39
|
-
# authenticated.
|
|
40
|
-
self._auth = {
|
|
41
|
-
"api-key": api_key,
|
|
42
|
-
"secret-key": secret_key,
|
|
43
|
-
"User-Agent": "hostpay-python/0.1.0",
|
|
44
|
-
}
|
|
45
|
-
self._client = http_client or httpx.Client(
|
|
46
|
-
base_url=base_url.rstrip("/"), timeout=timeout
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
def request(
|
|
50
|
-
self,
|
|
51
|
-
method: str,
|
|
52
|
-
path: str,
|
|
53
|
-
json: Optional[dict] = None,
|
|
54
|
-
params: Optional[dict] = None,
|
|
55
|
-
idempotency_key: Optional[str] = None,
|
|
56
|
-
) -> Any:
|
|
57
|
-
headers: Dict[str, str] = dict(self._auth)
|
|
58
|
-
if idempotency_key:
|
|
59
|
-
headers["Idempotency-Key"] = idempotency_key
|
|
60
|
-
# Retry only idempotent-safe conditions: connection errors and 5xx. POSTs
|
|
61
|
-
# are retried too — the API supports Idempotency-Key for money movements.
|
|
62
|
-
last_exc: Optional[Exception] = None
|
|
63
|
-
for attempt in range(self._max_retries + 1):
|
|
64
|
-
try:
|
|
65
|
-
resp = self._client.request(
|
|
66
|
-
method, path, json=json, params=params, headers=headers
|
|
67
|
-
)
|
|
68
|
-
except httpx.HTTPError as exc:
|
|
69
|
-
last_exc = exc
|
|
70
|
-
if attempt < self._max_retries:
|
|
71
|
-
time.sleep(0.5 * (2 ** attempt))
|
|
72
|
-
continue
|
|
73
|
-
raise APIConnectionError(f"Could not reach HostPay: {exc}") from exc
|
|
74
|
-
|
|
75
|
-
if resp.status_code >= 500 and attempt < self._max_retries:
|
|
76
|
-
time.sleep(0.5 * (2 ** attempt))
|
|
77
|
-
continue
|
|
78
|
-
return _handle_response(resp)
|
|
79
|
-
raise APIConnectionError(f"Could not reach HostPay: {last_exc}")
|
|
80
|
-
|
|
81
|
-
def close(self) -> None:
|
|
82
|
-
self._client.close()
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _handle_response(resp: "httpx.Response") -> Any:
|
|
86
|
-
if 200 <= resp.status_code < 300:
|
|
87
|
-
if not resp.content:
|
|
88
|
-
return None
|
|
89
|
-
return _wrap(resp.json())
|
|
90
|
-
try:
|
|
91
|
-
body = resp.json()
|
|
92
|
-
detail = body.get("detail", body) if isinstance(body, dict) else body
|
|
93
|
-
except ValueError:
|
|
94
|
-
detail = resp.text
|
|
95
|
-
message = detail if isinstance(detail, str) else f"HTTP {resp.status_code}"
|
|
96
|
-
raise error_from_status(resp.status_code, message, detail)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
class HostPay:
|
|
100
|
-
"""HostPay API client.
|
|
101
|
-
|
|
102
|
-
>>> client = HostPay(api_key="ak-...", secret_key="sk-...")
|
|
103
|
-
>>> user = client.users.create(app_user_id="u1", name="Alice", phone_number="+23279000000")
|
|
104
|
-
>>> wallet = client.wallets.create(user.id)
|
|
105
|
-
>>> client.deposits.mobile_money(wallet_id=wallet.id, amount=100)
|
|
106
|
-
"""
|
|
107
|
-
|
|
108
|
-
def __init__(
|
|
109
|
-
self,
|
|
110
|
-
api_key: str,
|
|
111
|
-
secret_key: str,
|
|
112
|
-
base_url: str = DEFAULT_BASE_URL,
|
|
113
|
-
timeout: float = 30.0,
|
|
114
|
-
max_retries: int = 2,
|
|
115
|
-
http_client: Optional[httpx.Client] = None,
|
|
116
|
-
) -> None:
|
|
117
|
-
if not api_key or not secret_key:
|
|
118
|
-
raise ValueError("api_key and secret_key are required")
|
|
119
|
-
self._transport = _Transport(
|
|
120
|
-
api_key, secret_key, base_url, timeout, max_retries, http_client
|
|
121
|
-
)
|
|
122
|
-
self.users = Users(self._transport)
|
|
123
|
-
self.wallets = Wallets(self._transport)
|
|
124
|
-
self.deposits = Deposits(self._transport)
|
|
125
|
-
self.transfers = Transfers(self._transport)
|
|
126
|
-
self.transactions = Transactions(self._transport)
|
|
127
|
-
self.payouts = Payouts(self._transport)
|
|
128
|
-
self.escrow = Escrow(self._transport)
|
|
129
|
-
self.webhooks = Webhooks()
|
|
130
|
-
|
|
131
|
-
def close(self) -> None:
|
|
132
|
-
self._transport.close()
|
|
133
|
-
|
|
134
|
-
def __enter__(self) -> "HostPay":
|
|
135
|
-
return self
|
|
136
|
-
|
|
137
|
-
def __exit__(self, *exc: object) -> None:
|
|
138
|
-
self.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|