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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hostpay
3
- Version: 0.2.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 # once published
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 # once published
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
- __version__ = "0.1.0"
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hostpay"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Python SDK for the HostPay payments API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -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"
@@ -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