clickpesa-python-sdk 0.1.0__py3-none-any.whl

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.
clickpesa/__init__.py ADDED
@@ -0,0 +1,146 @@
1
+ """
2
+ ClickPesa Python SDK
3
+ ====================
4
+
5
+ Sync usage::
6
+
7
+ from clickpesa import ClickPesa
8
+
9
+ with ClickPesa(client_id="…", api_key="…", sandbox=True) as cp:
10
+ balance = cp.account.get_balance()
11
+ cp.payments.initiate_ussd_push(amount="5000", phone="255712345678", order_id="ORD001")
12
+
13
+ Async usage::
14
+
15
+ from clickpesa import AsyncClickPesa
16
+
17
+ async with AsyncClickPesa(client_id="…", api_key="…", sandbox=True) as cp:
18
+ balance = await cp.account.get_balance()
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from ._version import __version__
24
+ from .client import ClickPesaClient
25
+ from .async_client import AsyncClickPesaClient
26
+ from .security import SecurityManager
27
+ from .webhooks import WebhookValidator
28
+ from .exceptions import (
29
+ ClickPesaError,
30
+ AuthenticationError,
31
+ ForbiddenError,
32
+ ValidationError,
33
+ InsufficientFundsError,
34
+ NotFoundError,
35
+ ConflictError,
36
+ RateLimitError,
37
+ ServerError,
38
+ )
39
+ from .services.payments import PaymentService, AsyncPaymentService
40
+ from .services.payouts import PayoutService, AsyncPayoutService
41
+ from .services.billpay import BillPayService, AsyncBillPayService
42
+ from .services.account import AccountService, AsyncAccountService
43
+ from .services.exchange import ExchangeService, AsyncExchangeService
44
+ from .services.links import LinkService, AsyncLinkService
45
+
46
+
47
+ class ClickPesa(ClickPesaClient):
48
+ """
49
+ Synchronous ClickPesa client with all service namespaces attached.
50
+
51
+ Attributes:
52
+ payments: :class:`~clickpesa.services.payments.PaymentService`
53
+ payouts: :class:`~clickpesa.services.payouts.PayoutService`
54
+ billpay: :class:`~clickpesa.services.billpay.BillPayService`
55
+ account: :class:`~clickpesa.services.account.AccountService`
56
+ exchange: :class:`~clickpesa.services.exchange.ExchangeService`
57
+ links: :class:`~clickpesa.services.links.LinkService`
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ client_id: str,
63
+ api_key: str,
64
+ checksum_key: str | None = None,
65
+ sandbox: bool = False,
66
+ timeout: float = 30.0,
67
+ max_retries: int = 3,
68
+ ) -> None:
69
+ super().__init__(client_id, api_key, checksum_key, sandbox, timeout, max_retries)
70
+ self.payments = PaymentService(self)
71
+ self.payouts = PayoutService(self)
72
+ self.billpay = BillPayService(self)
73
+ self.account = AccountService(self)
74
+ self.exchange = ExchangeService(self)
75
+ self.links = LinkService(self)
76
+
77
+
78
+ class AsyncClickPesa(AsyncClickPesaClient):
79
+ """
80
+ Asynchronous ClickPesa client with all service namespaces attached.
81
+
82
+ Attributes:
83
+ payments: :class:`~clickpesa.services.payments.AsyncPaymentService`
84
+ payouts: :class:`~clickpesa.services.payouts.AsyncPayoutService`
85
+ billpay: :class:`~clickpesa.services.billpay.AsyncBillPayService`
86
+ account: :class:`~clickpesa.services.account.AsyncAccountService`
87
+ exchange: :class:`~clickpesa.services.exchange.AsyncExchangeService`
88
+ links: :class:`~clickpesa.services.links.AsyncLinkService`
89
+ """
90
+
91
+ def __init__(
92
+ self,
93
+ client_id: str,
94
+ api_key: str,
95
+ checksum_key: str | None = None,
96
+ sandbox: bool = False,
97
+ timeout: float = 30.0,
98
+ max_retries: int = 3,
99
+ ) -> None:
100
+ super().__init__(client_id, api_key, checksum_key, sandbox, timeout, max_retries)
101
+ self.payments = AsyncPaymentService(self)
102
+ self.payouts = AsyncPayoutService(self)
103
+ self.billpay = AsyncBillPayService(self)
104
+ self.account = AsyncAccountService(self)
105
+ self.exchange = AsyncExchangeService(self)
106
+ self.links = AsyncLinkService(self)
107
+
108
+
109
+ __version__ = __version__
110
+
111
+ __all__ = [
112
+ # Main clients
113
+ "ClickPesa",
114
+ "AsyncClickPesa",
115
+ # Base clients (for advanced subclassing)
116
+ "ClickPesaClient",
117
+ "AsyncClickPesaClient",
118
+ # Utilities
119
+ "SecurityManager",
120
+ "WebhookValidator",
121
+ # Exceptions
122
+ "ClickPesaError",
123
+ "AuthenticationError",
124
+ "ForbiddenError",
125
+ "ValidationError",
126
+ "InsufficientFundsError",
127
+ "NotFoundError",
128
+ "ConflictError",
129
+ "RateLimitError",
130
+ "ServerError",
131
+ # Services (for type hints)
132
+ "PaymentService",
133
+ "AsyncPaymentService",
134
+ "PayoutService",
135
+ "AsyncPayoutService",
136
+ "BillPayService",
137
+ "AsyncBillPayService",
138
+ "AccountService",
139
+ "AsyncAccountService",
140
+ "ExchangeService",
141
+ "AsyncExchangeService",
142
+ "LinkService",
143
+ "AsyncLinkService",
144
+ # Version
145
+ "__version__",
146
+ ]
clickpesa/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,307 @@
1
+ """
2
+ Asynchronous ClickPesa HTTP client.
3
+
4
+ Drop-in async counterpart to :class:`~clickpesa.client.ClickPesaClient`.
5
+ Requires Python 3.10+ and ``httpx`` with async support (included by default).
6
+
7
+ Usage::
8
+
9
+ async with AsyncClickPesaClient(client_id="…", api_key="…") as client:
10
+ balance = await client.account.get_balance()
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import logging
17
+ import time
18
+ from typing import Any
19
+
20
+ import httpx
21
+
22
+ from .exceptions import (
23
+ AuthenticationError,
24
+ ClickPesaError,
25
+ ConflictError,
26
+ ForbiddenError,
27
+ InsufficientFundsError,
28
+ NotFoundError,
29
+ RateLimitError,
30
+ ServerError,
31
+ ValidationError,
32
+ )
33
+ from .security import SecurityManager
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _SANDBOX_URL = "https://api-sandbox.clickpesa.com"
38
+ _PRODUCTION_URL = "https://api.clickpesa.com"
39
+ _AUTH_PATH = "/third-parties/generate-token"
40
+ _TOKEN_TTL = 3300
41
+ _DEFAULT_TIMEOUT = 30.0
42
+ _MAX_RETRIES = 3
43
+ _RETRY_STATUSES = {500, 502, 503, 504}
44
+ _INSUFFICIENT_FUNDS_PHRASE = "Insufficient balance"
45
+
46
+
47
+ class AsyncClickPesaClient:
48
+ """
49
+ Production-grade asynchronous HTTP client for the ClickPesa API.
50
+
51
+ Features
52
+ --------
53
+ - Automatic JWT token acquisition and async-safe caching via ``asyncio.Lock``.
54
+ - Optional HMAC-SHA256 checksum injection on every mutating request.
55
+ - Exponential-backoff retries on transient 5xx errors.
56
+ - Structured exception hierarchy — never raises a bare ``Exception``.
57
+ - Async context-manager support (``async with`` statement).
58
+
59
+ Parameters
60
+ ----------
61
+ client_id:
62
+ Your ClickPesa application Client ID.
63
+ api_key:
64
+ Your ClickPesa application API key.
65
+ checksum_key:
66
+ Optional checksum secret. When provided every POST/PUT/PATCH request
67
+ automatically receives a ``checksum`` field.
68
+ sandbox:
69
+ Set ``True`` to target the sandbox environment.
70
+ timeout:
71
+ Request timeout in seconds (default: 30).
72
+ max_retries:
73
+ Maximum number of retry attempts on transient server errors (default: 3).
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ client_id: str,
79
+ api_key: str,
80
+ checksum_key: str | None = None,
81
+ sandbox: bool = False,
82
+ timeout: float = _DEFAULT_TIMEOUT,
83
+ max_retries: int = _MAX_RETRIES,
84
+ ) -> None:
85
+ self.client_id = client_id
86
+ self.api_key = api_key
87
+ self.checksum_key = checksum_key
88
+ self.base_url = _SANDBOX_URL if sandbox else _PRODUCTION_URL
89
+ self.timeout = timeout
90
+ self.max_retries = max_retries
91
+
92
+ # Async-safe token cache
93
+ self._token: str | None = None
94
+ self._token_expires_at: float = 0.0
95
+ self._lock: asyncio.Lock | None = None # created lazily inside the event loop
96
+
97
+ self._http = httpx.AsyncClient(
98
+ base_url=self.base_url,
99
+ headers={"Content-Type": "application/json"},
100
+ timeout=self.timeout,
101
+ )
102
+
103
+ def _get_lock(self) -> asyncio.Lock:
104
+ """Lazily create the asyncio.Lock inside the running event loop."""
105
+ if self._lock is None:
106
+ self._lock = asyncio.Lock()
107
+ return self._lock
108
+
109
+ # ------------------------------------------------------------------
110
+ # Authentication
111
+ # ------------------------------------------------------------------
112
+
113
+ async def _authenticate(self) -> str:
114
+ """Return a valid Bearer token, refreshing if necessary."""
115
+ async with self._get_lock():
116
+ now = time.monotonic()
117
+ if self._token and now < self._token_expires_at:
118
+ return self._token
119
+
120
+ logger.debug("Refreshing ClickPesa access token …")
121
+ try:
122
+ response = await self._http.post(
123
+ _AUTH_PATH,
124
+ headers={
125
+ "client-id": self.client_id,
126
+ "api-key": self.api_key,
127
+ },
128
+ )
129
+ except httpx.TransportError as exc:
130
+ raise ClickPesaError(
131
+ f"Network error during authentication: {exc}"
132
+ ) from exc
133
+
134
+ if response.status_code == 401:
135
+ raise AuthenticationError(
136
+ "Invalid client-id or api-key", status_code=401
137
+ )
138
+ if response.status_code == 403:
139
+ data = _safe_json(response)
140
+ raise ForbiddenError(
141
+ data.get("message", "Forbidden"),
142
+ status_code=403,
143
+ response=data,
144
+ )
145
+ if not response.is_success:
146
+ raise ClickPesaError(
147
+ f"Authentication failed ({response.status_code}): {response.text}",
148
+ status_code=response.status_code,
149
+ )
150
+
151
+ data = _safe_json(response)
152
+ token = data.get("token")
153
+ if not token:
154
+ raise ClickPesaError("Authentication response missing 'token' field")
155
+
156
+ self._token = token
157
+ self._token_expires_at = now + _TOKEN_TTL
158
+ logger.debug("Access token cached for %d seconds.", _TOKEN_TTL)
159
+ return self._token
160
+
161
+ # ------------------------------------------------------------------
162
+ # Core request dispatcher
163
+ # ------------------------------------------------------------------
164
+
165
+ async def request(
166
+ self,
167
+ method: str,
168
+ endpoint: str,
169
+ json: dict[str, Any] | None = None,
170
+ params: dict[str, Any] | None = None,
171
+ ) -> Any:
172
+ """
173
+ Execute an authenticated API request with automatic retry on 5xx errors.
174
+
175
+ Parameters
176
+ ----------
177
+ method: HTTP verb (``"GET"``, ``"POST"``, ``"PATCH"``, etc.).
178
+ endpoint: API path relative to the base URL (leading slash optional).
179
+ json: Request body — will NOT be mutated; a shallow copy is made.
180
+ params: Query-string parameters.
181
+
182
+ Returns
183
+ -------
184
+ Parsed JSON response body.
185
+
186
+ Raises
187
+ ------
188
+ :class:`~clickpesa.exceptions.ClickPesaError` or one of its subclasses.
189
+ """
190
+ token = await self._authenticate()
191
+ path = endpoint if endpoint.startswith("/") else f"/{endpoint}"
192
+
193
+ payload: dict[str, Any] | None = None
194
+ if json is not None:
195
+ payload = dict(json)
196
+ if self.checksum_key and method.upper() in {"POST", "PUT", "PATCH"}:
197
+ if "checksum" not in payload:
198
+ payload["checksum"] = SecurityManager.create_checksum(
199
+ self.checksum_key, payload
200
+ )
201
+
202
+ last_exc: Exception | None = None
203
+ for attempt in range(1, self.max_retries + 1):
204
+ try:
205
+ response = await self._http.request(
206
+ method=method,
207
+ url=path,
208
+ json=payload,
209
+ params=params,
210
+ headers={"Authorization": token},
211
+ )
212
+ except httpx.TransportError as exc:
213
+ last_exc = exc
214
+ if attempt < self.max_retries:
215
+ await _async_backoff(attempt)
216
+ continue
217
+ raise ClickPesaError(f"Network error: {exc}") from exc
218
+
219
+ if response.status_code in _RETRY_STATUSES and attempt < self.max_retries:
220
+ logger.warning(
221
+ "Received %d on attempt %d/%d — retrying …",
222
+ response.status_code,
223
+ attempt,
224
+ self.max_retries,
225
+ )
226
+ await _async_backoff(attempt)
227
+ continue
228
+
229
+ return _handle_response(response)
230
+
231
+ raise ClickPesaError(
232
+ f"Request failed after {self.max_retries} attempts"
233
+ ) from last_exc
234
+
235
+ # ------------------------------------------------------------------
236
+ # Utilities
237
+ # ------------------------------------------------------------------
238
+
239
+ async def is_healthy(self) -> bool:
240
+ """
241
+ Perform a lightweight connectivity and credential check.
242
+
243
+ Returns ``True`` if the API is reachable and credentials are valid.
244
+ """
245
+ try:
246
+ await self.request("GET", "/third-parties/account/balance")
247
+ return True
248
+ except Exception:
249
+ return False
250
+
251
+ async def close(self) -> None:
252
+ """Close the underlying async HTTP connection pool."""
253
+ await self._http.aclose()
254
+
255
+ # Async context-manager protocol
256
+ async def __aenter__(self) -> "AsyncClickPesaClient":
257
+ return self
258
+
259
+ async def __aexit__(self, *_: Any) -> None:
260
+ await self.close()
261
+
262
+
263
+ # ------------------------------------------------------------------
264
+ # Private helpers (module-level to share with sync client)
265
+ # ------------------------------------------------------------------
266
+
267
+ def _safe_json(response: httpx.Response) -> Any:
268
+ try:
269
+ return response.json()
270
+ except Exception:
271
+ return {"message": response.text}
272
+
273
+
274
+ def _handle_response(response: httpx.Response) -> Any:
275
+ """Map HTTP status codes to structured exceptions."""
276
+ data = _safe_json(response)
277
+
278
+ if response.is_success:
279
+ return data
280
+
281
+ msg = data.get("message", "Unknown API error") if isinstance(data, dict) else str(data)
282
+ status = response.status_code
283
+
284
+ if status == 400:
285
+ if _INSUFFICIENT_FUNDS_PHRASE in msg:
286
+ raise InsufficientFundsError(msg, status_code=status, response=data)
287
+ raise ValidationError(msg, status_code=status, response=data)
288
+ if status == 401:
289
+ raise AuthenticationError(msg, status_code=status, response=data)
290
+ if status == 403:
291
+ raise ForbiddenError(msg, status_code=status, response=data)
292
+ if status == 404:
293
+ raise NotFoundError(msg, status_code=status, response=data)
294
+ if status == 409:
295
+ raise ConflictError(msg, status_code=status, response=data)
296
+ if status == 429:
297
+ raise RateLimitError(msg, status_code=status, response=data)
298
+ if status >= 500:
299
+ raise ServerError(msg, status_code=status, response=data)
300
+
301
+ raise ClickPesaError(msg, status_code=status, response=data)
302
+
303
+
304
+ async def _async_backoff(attempt: int) -> None:
305
+ delay = 2 ** (attempt - 1)
306
+ logger.debug("Retrying in %d second(s) …", delay)
307
+ await asyncio.sleep(delay)