kibonet-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.
@@ -0,0 +1,269 @@
1
+ Metadata-Version: 2.4
2
+ Name: kibonet-python-sdk
3
+ Version: 0.1.0
4
+ Summary: Simple, secure Python SDK for the KiboNet SMS Gateway
5
+ Author-email: Jackson Linus <jacksonlinus95@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/JAXPARROW/kibonet-python-sdk
8
+ Project-URL: Repository, https://github.com/JAXPARROW/kibonet-python-sdk.git
9
+ Project-URL: Issues, https://github.com/JAXPARROW/kibonet-python-sdk/issues
10
+ Keywords: kibonet,sms,tanzania,africa,messaging,httpx,async
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.8
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Communications :: Telephony
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Framework :: AsyncIO
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: httpx>=0.24.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
29
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
30
+ Requires-Dist: respx>=0.20.0; extra == "dev"
31
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
32
+ Requires-Dist: black>=23.0.0; extra == "dev"
33
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
34
+ Requires-Dist: build>=1.0.0; extra == "dev"
35
+ Requires-Dist: twine>=4.0.0; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # KiboNet Python SDK
39
+
40
+ Simple, secure Python SDK for the [KiboNet SMS Gateway](https://sms.kibonet.co.tz).
41
+ Supports both **synchronous** and **async** usage via [httpx](https://www.python-httpx.org/).
42
+
43
+ [![PyPI version](https://img.shields.io/pypi/v/kibonet-python-sdk)](https://pypi.org/project/kibonet-python-sdk/)
44
+ [![Python Versions](https://img.shields.io/pypi/pyversions/kibonet-python-sdk)](https://pypi.org/project/kibonet-python-sdk/)
45
+ [![License: MIT](https://img.shields.io/pypi/l/kibonet-python-sdk)](https://pypi.org/project/kibonet-python-sdk/)
46
+
47
+ ## Features
48
+
49
+ - **Send SMS** to one or multiple recipients (comma-separated contacts)
50
+ - **Delivery reports** retrieved by `shootId`
51
+ - **Balance check** for your SMS credits
52
+ - **Health check** — `is_healthy()` never raises, perfect for monitoring probes
53
+ - **Async support** — full `async`/`await` via `AsyncKiboNetClient`
54
+ - **httpx** under the hood — connection pooling, automatic retries, TLS verification
55
+ - **Phone number validation** — auto-converts `0XXXXXXXXX` → `255XXXXXXXXX`
56
+ - **Typed exceptions** — catch exactly what you need
57
+ - **Context manager** support (both sync and async)
58
+ - **No credentials in code** — load from environment variables
59
+
60
+ ## Installation
61
+
62
+ ```bash
63
+ pip install kibonet-python-sdk
64
+ ```
65
+
66
+ ## Quick Start
67
+
68
+ ### Synchronous
69
+
70
+ ```python
71
+ from kibonet_sdk import KiboNetClient
72
+
73
+ with KiboNetClient(api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET") as client:
74
+ # Health check
75
+ if client.is_healthy():
76
+ print("API is up!")
77
+
78
+ # Send SMS
79
+ resp = client.send_sms(
80
+ sender_id="MyBrand",
81
+ message="Hello from KiboNet!",
82
+ contacts=["255712345678", "255798765432"],
83
+ delivery_report_url="https://myserver.com/delivery-callback", # optional
84
+ )
85
+ print(f"Shoot ID: {resp['data']['shootId']}")
86
+ print(f"Valid: {resp['data']['validContacts']}")
87
+ ```
88
+
89
+ ### Asynchronous
90
+
91
+ ```python
92
+ import asyncio
93
+ from kibonet_sdk import AsyncKiboNetClient
94
+
95
+ async def main():
96
+ async with AsyncKiboNetClient(api_key="YOUR_API_KEY", api_secret="YOUR_API_SECRET") as client:
97
+ if await client.is_healthy():
98
+ resp = await client.send_sms(
99
+ sender_id="MyBrand",
100
+ message="Hello async!",
101
+ contacts="255712345678",
102
+ )
103
+ print(resp["data"]["shootId"])
104
+
105
+ asyncio.run(main())
106
+ ```
107
+
108
+ ## All Methods
109
+
110
+ Both `KiboNetClient` and `AsyncKiboNetClient` expose the same interface:
111
+
112
+ ### `send_sms`
113
+
114
+ ```python
115
+ resp = client.send_sms(
116
+ sender_id="MyBrand", # Sender name shown to recipient
117
+ message="Your OTP is 1234", # SMS text
118
+ contacts=[ # Single string, list, or CSV string
119
+ "255712345678",
120
+ "255798765432",
121
+ ],
122
+ delivery_report_url="https://myserver.com/callback", # optional
123
+ message_type="text", # currently only "text" is supported
124
+ )
125
+
126
+ # Response
127
+ print(resp["data"]["shootId"]) # Use this for delivery reports
128
+ print(resp["data"]["validContacts"]) # int
129
+ print(resp["data"]["invalidContacts"]) # int
130
+ print(resp["data"]["duplicatedContacts"])
131
+ print(resp["data"]["messageSize"]) # character count
132
+ ```
133
+
134
+ ### `get_delivery_report`
135
+
136
+ ```python
137
+ report = client.get_delivery_report("abcd1234-5678-efgh-9012-ijklmnopqrst")
138
+
139
+ for record in report["data"]:
140
+ print(record["mobile"]) # 255712345678
141
+ print(record["status"]) # Delivered / Failed / Buffered
142
+ print(record["statusCode"]) # 000
143
+ print(record["sentAt"]) # "07-02-2024 07:35:08"
144
+ ```
145
+
146
+ ### `get_balance`
147
+
148
+ ```python
149
+ result = client.get_balance()
150
+ print(result["data"]["totalSms"]) # e.g. 30
151
+ ```
152
+
153
+ ### `is_healthy`
154
+
155
+ ```python
156
+ if client.is_healthy():
157
+ print("API is reachable and credentials are valid")
158
+ ```
159
+
160
+ ## Phone Number Formats
161
+
162
+ The SDK automatically normalises phone numbers:
163
+
164
+ | Input format | Stored as |
165
+ |--------------------|----------------|
166
+ | `0712345678` | `255712345678` |
167
+ | `+255712345678` | `255712345678` |
168
+ | `255712345678` | `255712345678` |
169
+
170
+ ## Error Handling
171
+
172
+ ```python
173
+ from kibonet_sdk import KiboNetClient
174
+ from kibonet_sdk.exceptions import (
175
+ KiboNetAuthenticationError,
176
+ KiboNetValidationError,
177
+ KiboNetRateLimitError,
178
+ KiboNetInsufficientBalanceError,
179
+ KiboNetConnectionError,
180
+ KiboNetServerError,
181
+ KiboNetAPIError,
182
+ KiboNetError, # base — catch everything SDK-related
183
+ )
184
+
185
+ client = KiboNetClient(api_key="...", api_secret="...")
186
+
187
+ try:
188
+ client.send_sms(sender_id="X", message="Hi", contacts="255712345678")
189
+ except KiboNetAuthenticationError:
190
+ print("Bad credentials")
191
+ except KiboNetValidationError as e:
192
+ print(f"Input error: {e}")
193
+ except KiboNetRateLimitError:
194
+ print("Slow down — rate limited")
195
+ except KiboNetConnectionError as e:
196
+ print(f"Network error: {e}")
197
+ except KiboNetServerError as e:
198
+ print(f"KiboNet server problem: {e}")
199
+ except KiboNetAPIError as e:
200
+ print(f"API error (code={e.code}): {e}")
201
+ ```
202
+
203
+ ## Environment Variables
204
+
205
+ Keep credentials out of source code:
206
+
207
+ ```bash
208
+ export KIBONET_API_KEY="your_api_key"
209
+ export KIBONET_API_SECRET="your_api_secret"
210
+ ```
211
+
212
+ ```python
213
+ import os
214
+ from kibonet_sdk import KiboNetClient
215
+
216
+ client = KiboNetClient(
217
+ api_key=os.environ["KIBONET_API_KEY"],
218
+ api_secret=os.environ["KIBONET_API_SECRET"],
219
+ )
220
+ ```
221
+
222
+ ## Constructor Options
223
+
224
+ ```python
225
+ KiboNetClient(
226
+ api_key: str, # Required — from KiboNet portal
227
+ api_secret: str, # Required — from KiboNet portal
228
+ timeout: float = 30.0, # Per-request timeout in seconds
229
+ max_retries: int = 3, # Automatic retries on transient failures
230
+ logger=None, # Optional logging.Logger
231
+ )
232
+ ```
233
+
234
+ `AsyncKiboNetClient` takes the same parameters.
235
+
236
+ ## Logging
237
+
238
+ ```python
239
+ import logging
240
+ logging.basicConfig(level=logging.DEBUG)
241
+
242
+ client = KiboNetClient(api_key="...", api_secret="...")
243
+ # All HTTP calls are logged at DEBUG level
244
+ ```
245
+
246
+ ## Development
247
+
248
+ ```bash
249
+ git clone https://github.com/JAXPARROW/kibonet-python-sdk.git
250
+ cd kibonet-python-sdk
251
+ pip install -e ".[dev]"
252
+
253
+ # Run tests
254
+ pytest
255
+
256
+ # Run with coverage
257
+ pytest --cov=kibonet_sdk --cov-report=term-missing
258
+
259
+ # Build
260
+ python -m build
261
+ ```
262
+
263
+ ## License
264
+
265
+ MIT — see [LICENSE](LICENSE).
266
+
267
+ ## Contributors
268
+
269
+ - **Jackson Linus** — Creator and maintainer
@@ -0,0 +1,13 @@
1
+ kibonet_python_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=iURnGbrOhoQubmqcORxyMl5RlR_uiiXwgrQC7m_mpCo,1070
2
+ kibonet_sdk/__init__.py,sha256=N96hQ3EHlVEoqGoSjFKPNsi02fqUGxagWhQWhO-8swY,1539
3
+ kibonet_sdk/async_client.py,sha256=RUyeR70YwnwcVfOK1_7tlipFg8tuZXu1hfHPDuY0DsA,8290
4
+ kibonet_sdk/client.py,sha256=2bPsLGbhkCbbnREvRZC7eBLk_c82IWbpkqDpsM_hk0w,9610
5
+ kibonet_sdk/exceptions.py,sha256=ijqq0awLmGlB6a5mipSQmTIGIa590Oxktmb3Muncg2o,1134
6
+ kibonet_sdk/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ kibonet_sdk/utils/async_http_client.py,sha256=pShPDmVmt00Bo4_uG0LBv_4pI15N0liSWchKZQnI-sg,3357
8
+ kibonet_sdk/utils/http_client.py,sha256=hnM1LfHMLJkNgGOZfjh3h7KcP2YA1AYWdXkLQSw7B84,3304
9
+ kibonet_sdk/utils/validators.py,sha256=hTOtk0uVVwPip3gncnE_We4sFF-YD_2-jcUuSE97WKA,3138
10
+ kibonet_python_sdk-0.1.0.dist-info/METADATA,sha256=BiilOrTNOTtpAqaUWu6XpgXLyy2ygAHS_H6Uxl7fIOM,7843
11
+ kibonet_python_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ kibonet_python_sdk-0.1.0.dist-info/top_level.txt,sha256=EkSeCWl3t36U6xJyaOPj0Zm_leyv4lAlxN1KQ5uxVBc,12
13
+ kibonet_python_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Jackson Linus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ kibonet_sdk
@@ -0,0 +1,61 @@
1
+ """
2
+ KiboNet Python SDK — simple, secure SMS gateway client for Python.
3
+
4
+ Quickstart (sync)::
5
+
6
+ from kibonet_sdk import KiboNetClient
7
+
8
+ with KiboNetClient(api_key="YOUR_KEY", api_secret="YOUR_SECRET") as client:
9
+ resp = client.send_sms(
10
+ sender_id="MyBrand",
11
+ message="Hello from KiboNet!",
12
+ contacts=["255712345678"],
13
+ )
14
+ print(resp["data"]["shootId"])
15
+
16
+ Quickstart (async)::
17
+
18
+ import asyncio
19
+ from kibonet_sdk import AsyncKiboNetClient
20
+
21
+ async def main():
22
+ async with AsyncKiboNetClient(api_key="YOUR_KEY", api_secret="YOUR_SECRET") as client:
23
+ resp = await client.send_sms(
24
+ sender_id="MyBrand",
25
+ message="Hello async!",
26
+ contacts=["255712345678"],
27
+ )
28
+ print(resp["data"]["shootId"])
29
+
30
+ asyncio.run(main())
31
+ """
32
+
33
+ from .client import KiboNetClient
34
+ from .async_client import AsyncKiboNetClient
35
+ from .exceptions import (
36
+ KiboNetError,
37
+ KiboNetAuthenticationError,
38
+ KiboNetValidationError,
39
+ KiboNetAPIError,
40
+ KiboNetRateLimitError,
41
+ KiboNetInsufficientBalanceError,
42
+ KiboNetConnectionError,
43
+ KiboNetServerError,
44
+ )
45
+
46
+ __all__ = [
47
+ # Clients
48
+ "KiboNetClient",
49
+ "AsyncKiboNetClient",
50
+ # Exceptions
51
+ "KiboNetError",
52
+ "KiboNetAuthenticationError",
53
+ "KiboNetValidationError",
54
+ "KiboNetAPIError",
55
+ "KiboNetRateLimitError",
56
+ "KiboNetInsufficientBalanceError",
57
+ "KiboNetConnectionError",
58
+ "KiboNetServerError",
59
+ ]
60
+
61
+ __version__ = "0.1.0"
@@ -0,0 +1,238 @@
1
+ """
2
+ Asynchronous KiboNet SMS client.
3
+ """
4
+
5
+ import logging
6
+ from typing import Any, Dict, List, Optional, Union
7
+
8
+ import httpx
9
+
10
+ from .exceptions import (
11
+ KiboNetAPIError,
12
+ KiboNetAuthenticationError,
13
+ KiboNetConnectionError,
14
+ KiboNetRateLimitError,
15
+ KiboNetServerError,
16
+ KiboNetValidationError,
17
+ )
18
+ from .utils.async_http_client import AsyncHTTPClient
19
+ from .utils.validators import PhoneNumberValidator
20
+
21
+
22
+ _SEND_ENDPOINT = "api/v1/vendor/message/send"
23
+ _DELIVER_ENDPOINT = "api/v1/vendor/message/deliver/{shoot_id}"
24
+ _BALANCE_ENDPOINT = "api/v1/vendor/message/balance"
25
+
26
+
27
+ class AsyncKiboNetClient:
28
+ """
29
+ Asynchronous client for the KiboNet SMS API.
30
+
31
+ Supports the same operations as :class:`~kibonet_sdk.KiboNetClient` but
32
+ exposes a fully ``async``/``await`` interface powered by *httpx*.
33
+
34
+ Example::
35
+
36
+ import asyncio
37
+ from kibonet_sdk import AsyncKiboNetClient
38
+
39
+ async def main():
40
+ async with AsyncKiboNetClient(api_key="...", api_secret="...") as client:
41
+ if await client.is_healthy():
42
+ resp = await client.send_sms(
43
+ sender_id="MyBrand",
44
+ message="Hello async!",
45
+ contacts=["255712345678"],
46
+ )
47
+ print(resp["data"]["shootId"])
48
+
49
+ asyncio.run(main())
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ api_key: str,
55
+ api_secret: str,
56
+ timeout: float = 30.0,
57
+ max_retries: int = 3,
58
+ logger: Optional[logging.Logger] = None,
59
+ ):
60
+ """
61
+ Initialise the KiboNet asynchronous client.
62
+
63
+ Args:
64
+ api_key: API key from the KiboNet portal.
65
+ api_secret: API secret from the KiboNet portal.
66
+ timeout: Per-request timeout in seconds (default 30).
67
+ max_retries: Number of automatic retries on transient errors (default 3).
68
+ logger: Optional custom :class:`logging.Logger`.
69
+ """
70
+ if not api_key or not isinstance(api_key, str):
71
+ raise KiboNetValidationError("api_key must be a non-empty string")
72
+ if not api_secret or not isinstance(api_secret, str):
73
+ raise KiboNetValidationError("api_secret must be a non-empty string")
74
+
75
+ self.logger = logger or logging.getLogger(__name__)
76
+ self._http = AsyncHTTPClient(
77
+ api_key=api_key,
78
+ api_secret=api_secret,
79
+ timeout=timeout,
80
+ max_retries=max_retries,
81
+ logger=self.logger,
82
+ )
83
+
84
+ # ------------------------------------------------------------------
85
+ # Public API
86
+ # ------------------------------------------------------------------
87
+
88
+ async def is_healthy(self) -> bool:
89
+ """
90
+ Asynchronously check whether the KiboNet API is reachable.
91
+
92
+ Never raises — always returns a boolean.
93
+
94
+ Returns:
95
+ ``True`` if the API responds with HTTP 200, ``False`` otherwise.
96
+ """
97
+ try:
98
+ response = await self._http.request(
99
+ "GET", _BALANCE_ENDPOINT, timeout=5.0
100
+ )
101
+ return response.status_code == 200
102
+ except Exception:
103
+ return False
104
+
105
+ async def send_sms(
106
+ self,
107
+ sender_id: str,
108
+ message: str,
109
+ contacts: Union[str, List[str]],
110
+ delivery_report_url: str = "",
111
+ message_type: str = "text",
112
+ ) -> Dict[str, Any]:
113
+ """
114
+ Asynchronously send a text SMS to one or more recipients.
115
+
116
+ Args:
117
+ sender_id: Sender name/ID displayed to recipients.
118
+ message: Text content of the SMS.
119
+ contacts: A single phone number, a list of phone numbers, or an
120
+ already comma-separated string.
121
+ delivery_report_url: Optional callback URL for delivery status.
122
+ message_type: Message type — currently only ``"text"`` is supported.
123
+
124
+ Returns:
125
+ Parsed API response dict (same structure as the sync version).
126
+
127
+ Raises:
128
+ KiboNetValidationError: Invalid input.
129
+ KiboNetAuthenticationError: Invalid credentials.
130
+ KiboNetRateLimitError: Rate limit exceeded.
131
+ KiboNetServerError: KiboNet server error.
132
+ KiboNetAPIError: Any other API-level error.
133
+ KiboNetConnectionError: Network failure.
134
+ """
135
+ if not sender_id or not isinstance(sender_id, str):
136
+ raise KiboNetValidationError("sender_id must be a non-empty string")
137
+ if not message or not isinstance(message, str):
138
+ raise KiboNetValidationError("message must be a non-empty string")
139
+
140
+ contacts_csv = PhoneNumberValidator.to_csv(contacts)
141
+
142
+ payload: Dict[str, Any] = {
143
+ "senderId": sender_id,
144
+ "messageType": message_type,
145
+ "message": message,
146
+ "contacts": contacts_csv,
147
+ }
148
+ if delivery_report_url:
149
+ payload["deliveryReportUrl"] = delivery_report_url
150
+
151
+ response = await self._http.request("POST", _SEND_ENDPOINT, json=payload)
152
+ return self._handle_response(response)
153
+
154
+ async def get_delivery_report(self, shoot_id: str) -> Dict[str, Any]:
155
+ """
156
+ Asynchronously retrieve delivery reports for a sent message.
157
+
158
+ Args:
159
+ shoot_id: The ``shootId`` returned by :meth:`send_sms`.
160
+
161
+ Returns:
162
+ Parsed API response dict (same structure as the sync version).
163
+
164
+ Raises:
165
+ KiboNetValidationError: If ``shoot_id`` is empty.
166
+ KiboNetAPIError: Any other API-level error.
167
+ KiboNetConnectionError: Network failure.
168
+ """
169
+ if not shoot_id or not isinstance(shoot_id, str):
170
+ raise KiboNetValidationError("shoot_id must be a non-empty string")
171
+
172
+ endpoint = _DELIVER_ENDPOINT.format(shoot_id=shoot_id)
173
+ response = await self._http.request("GET", endpoint)
174
+ return self._handle_response(response)
175
+
176
+ async def get_balance(self) -> Dict[str, Any]:
177
+ """
178
+ Asynchronously check the current SMS credit balance.
179
+
180
+ Returns:
181
+ Parsed API response dict (same structure as the sync version).
182
+
183
+ Raises:
184
+ KiboNetAuthenticationError: Invalid credentials.
185
+ KiboNetAPIError: Any other API-level error.
186
+ KiboNetConnectionError: Network failure.
187
+ """
188
+ response = await self._http.request("GET", _BALANCE_ENDPOINT)
189
+ return self._handle_response(response)
190
+
191
+ # ------------------------------------------------------------------
192
+ # Resource management
193
+ # ------------------------------------------------------------------
194
+
195
+ async def aclose(self) -> None:
196
+ """Close underlying HTTP connections and free resources."""
197
+ await self._http.aclose()
198
+
199
+ async def __aenter__(self):
200
+ return self
201
+
202
+ async def __aexit__(self, *args):
203
+ await self.aclose()
204
+
205
+ # ------------------------------------------------------------------
206
+ # Internal helpers
207
+ # ------------------------------------------------------------------
208
+
209
+ def _handle_response(self, response: httpx.Response) -> Dict[str, Any]:
210
+ """Map HTTP / API error codes to SDK exceptions."""
211
+ if response.status_code == 401:
212
+ raise KiboNetAuthenticationError(
213
+ "Authentication failed — check your api_key and api_secret"
214
+ )
215
+ if response.status_code == 429:
216
+ raise KiboNetRateLimitError("Rate limit exceeded", code=429)
217
+ if 500 <= response.status_code < 600:
218
+ raise KiboNetServerError(
219
+ f"KiboNet server error (HTTP {response.status_code})",
220
+ code=response.status_code,
221
+ )
222
+
223
+ try:
224
+ data = response.json()
225
+ except Exception:
226
+ raise KiboNetAPIError(
227
+ f"Unexpected non-JSON response (HTTP {response.status_code}): "
228
+ f"{response.text[:200]}"
229
+ )
230
+
231
+ if response.status_code != 200 or not data.get("success", False):
232
+ raise KiboNetAPIError(
233
+ data.get("message", f"API error (HTTP {response.status_code})"),
234
+ code=data.get("code"),
235
+ response_data=data,
236
+ )
237
+
238
+ return data
kibonet_sdk/client.py ADDED
@@ -0,0 +1,279 @@
1
+ """
2
+ Synchronous KiboNet SMS client.
3
+ """
4
+
5
+ import logging
6
+ from typing import Any, Dict, List, Optional, Union
7
+
8
+ import httpx
9
+
10
+ from .exceptions import (
11
+ KiboNetAPIError,
12
+ KiboNetAuthenticationError,
13
+ KiboNetConnectionError,
14
+ KiboNetRateLimitError,
15
+ KiboNetServerError,
16
+ KiboNetValidationError,
17
+ )
18
+ from .utils.http_client import HTTPClient
19
+ from .utils.validators import PhoneNumberValidator
20
+
21
+
22
+ _SEND_ENDPOINT = "api/v1/vendor/message/send"
23
+ _DELIVER_ENDPOINT = "api/v1/vendor/message/deliver/{shoot_id}"
24
+ _BALANCE_ENDPOINT = "api/v1/vendor/message/balance"
25
+
26
+
27
+ class KiboNetClient:
28
+ """
29
+ Synchronous client for the KiboNet SMS API.
30
+
31
+ Supports:
32
+ - Sending SMS to one or multiple recipients
33
+ - Retrieving delivery reports by shoot ID
34
+ - Checking your SMS balance
35
+ - Health-check via the balance endpoint
36
+
37
+ Example::
38
+
39
+ from kibonet_sdk import KiboNetClient
40
+
41
+ with KiboNetClient(api_key="...", api_secret="...") as client:
42
+ if client.is_healthy():
43
+ resp = client.send_sms(
44
+ sender_id="MyBrand",
45
+ message="Hello from KiboNet!",
46
+ contacts=["255712345678", "255798765432"],
47
+ delivery_report_url="https://myserver.com/delivery",
48
+ )
49
+ print(resp["data"]["shootId"])
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ api_key: str,
55
+ api_secret: str,
56
+ timeout: float = 30.0,
57
+ max_retries: int = 3,
58
+ logger: Optional[logging.Logger] = None,
59
+ ):
60
+ """
61
+ Initialise the KiboNet synchronous client.
62
+
63
+ Args:
64
+ api_key: API key from the KiboNet portal.
65
+ api_secret: API secret from the KiboNet portal.
66
+ timeout: Per-request timeout in seconds (default 30).
67
+ max_retries: Number of automatic retries on transient errors (default 3).
68
+ logger: Optional custom :class:`logging.Logger`.
69
+ """
70
+ if not api_key or not isinstance(api_key, str):
71
+ raise KiboNetValidationError("api_key must be a non-empty string")
72
+ if not api_secret or not isinstance(api_secret, str):
73
+ raise KiboNetValidationError("api_secret must be a non-empty string")
74
+
75
+ self.logger = logger or logging.getLogger(__name__)
76
+ self._http = HTTPClient(
77
+ api_key=api_key,
78
+ api_secret=api_secret,
79
+ timeout=timeout,
80
+ max_retries=max_retries,
81
+ logger=self.logger,
82
+ )
83
+
84
+ # ------------------------------------------------------------------
85
+ # Public API
86
+ # ------------------------------------------------------------------
87
+
88
+ def is_healthy(self) -> bool:
89
+ """
90
+ Check whether the KiboNet API is reachable and credentials are valid.
91
+
92
+ Never raises — always returns a boolean. Suitable for liveness /
93
+ readiness probes in containerised deployments.
94
+
95
+ Returns:
96
+ ``True`` if the API responds with HTTP 200, ``False`` otherwise.
97
+ """
98
+ try:
99
+ response = self._http.request("GET", _BALANCE_ENDPOINT, timeout=5.0)
100
+ return response.status_code == 200
101
+ except Exception:
102
+ return False
103
+
104
+ def send_sms(
105
+ self,
106
+ sender_id: str,
107
+ message: str,
108
+ contacts: Union[str, List[str]],
109
+ delivery_report_url: str = "",
110
+ message_type: str = "text",
111
+ ) -> Dict[str, Any]:
112
+ """
113
+ Send a text SMS to one or more recipients.
114
+
115
+ Args:
116
+ sender_id: Sender name/ID displayed to recipients.
117
+ message: Text content of the SMS.
118
+ contacts: A single phone number, a list of phone numbers, or an
119
+ already comma-separated string.
120
+ delivery_report_url: Optional callback URL for delivery status updates.
121
+ message_type: Message type — currently only ``"text"`` is supported.
122
+
123
+ Returns:
124
+ Parsed API response dict, e.g.::
125
+
126
+ {
127
+ "code": 200,
128
+ "data": {
129
+ "validContacts": 2,
130
+ "invalidContacts": 0,
131
+ "duplicatedContacts": 0,
132
+ "messageSize": 20,
133
+ "message": "Hello!",
134
+ "shootId": "abcd1234-…"
135
+ },
136
+ "success": True,
137
+ "message": "Message submit"
138
+ }
139
+
140
+ Raises:
141
+ KiboNetValidationError: Invalid input parameters.
142
+ KiboNetAuthenticationError: Invalid credentials.
143
+ KiboNetRateLimitError: Rate limit exceeded.
144
+ KiboNetServerError: KiboNet server error.
145
+ KiboNetAPIError: Any other API-level error.
146
+ KiboNetConnectionError: Network failure.
147
+ """
148
+ if not sender_id or not isinstance(sender_id, str):
149
+ raise KiboNetValidationError("sender_id must be a non-empty string")
150
+ if not message or not isinstance(message, str):
151
+ raise KiboNetValidationError("message must be a non-empty string")
152
+
153
+ contacts_csv = PhoneNumberValidator.to_csv(contacts)
154
+
155
+ payload: Dict[str, Any] = {
156
+ "senderId": sender_id,
157
+ "messageType": message_type,
158
+ "message": message,
159
+ "contacts": contacts_csv,
160
+ }
161
+ if delivery_report_url:
162
+ payload["deliveryReportUrl"] = delivery_report_url
163
+
164
+ response = self._http.request("POST", _SEND_ENDPOINT, json=payload)
165
+ return self._handle_response(response)
166
+
167
+ def get_delivery_report(self, shoot_id: str) -> Dict[str, Any]:
168
+ """
169
+ Retrieve delivery status reports for a previously sent message.
170
+
171
+ Args:
172
+ shoot_id: The ``shootId`` returned by :meth:`send_sms`.
173
+
174
+ Returns:
175
+ Parsed API response dict, e.g.::
176
+
177
+ {
178
+ "code": 200,
179
+ "data": [
180
+ {
181
+ "message": "Hello!",
182
+ "mobile": "255712345678",
183
+ "senderId": "MyBrand",
184
+ "status": "Delivered",
185
+ "statusCode": "000",
186
+ "explanation": "N/A",
187
+ "messageType": "text",
188
+ "sentAt": "07-02-2024 07:35:08"
189
+ }
190
+ ],
191
+ "success": True,
192
+ "message": "Sent messages report"
193
+ }
194
+
195
+ Raises:
196
+ KiboNetValidationError: If ``shoot_id`` is empty.
197
+ KiboNetAuthenticationError: Invalid credentials.
198
+ KiboNetAPIError: Any other API-level error.
199
+ KiboNetConnectionError: Network failure.
200
+ """
201
+ if not shoot_id or not isinstance(shoot_id, str):
202
+ raise KiboNetValidationError("shoot_id must be a non-empty string")
203
+
204
+ endpoint = _DELIVER_ENDPOINT.format(shoot_id=shoot_id)
205
+ response = self._http.request("GET", endpoint)
206
+ return self._handle_response(response)
207
+
208
+ def get_balance(self) -> Dict[str, Any]:
209
+ """
210
+ Check the current SMS credit balance for your account.
211
+
212
+ Returns:
213
+ Parsed API response dict, e.g.::
214
+
215
+ {
216
+ "code": 200,
217
+ "data": {"totalSms": 30},
218
+ "success": True,
219
+ "message": "Balance"
220
+ }
221
+
222
+ Raises:
223
+ KiboNetAuthenticationError: Invalid credentials.
224
+ KiboNetAPIError: Any other API-level error.
225
+ KiboNetConnectionError: Network failure.
226
+ """
227
+ response = self._http.request("GET", _BALANCE_ENDPOINT)
228
+ return self._handle_response(response)
229
+
230
+ # ------------------------------------------------------------------
231
+ # Resource management
232
+ # ------------------------------------------------------------------
233
+
234
+ def close(self) -> None:
235
+ """Close underlying HTTP connections and free resources."""
236
+ self._http.close()
237
+
238
+ def __enter__(self):
239
+ return self
240
+
241
+ def __exit__(self, *args):
242
+ self.close()
243
+
244
+ # ------------------------------------------------------------------
245
+ # Internal helpers
246
+ # ------------------------------------------------------------------
247
+
248
+ def _handle_response(self, response: httpx.Response) -> Dict[str, Any]:
249
+ """Map HTTP / API error codes to SDK exceptions."""
250
+ if response.status_code == 401:
251
+ raise KiboNetAuthenticationError(
252
+ "Authentication failed — check your api_key and api_secret"
253
+ )
254
+ if response.status_code == 429:
255
+ raise KiboNetRateLimitError(
256
+ "Rate limit exceeded", code=429
257
+ )
258
+ if 500 <= response.status_code < 600:
259
+ raise KiboNetServerError(
260
+ f"KiboNet server error (HTTP {response.status_code})",
261
+ code=response.status_code,
262
+ )
263
+
264
+ try:
265
+ data = response.json()
266
+ except Exception:
267
+ raise KiboNetAPIError(
268
+ f"Unexpected non-JSON response (HTTP {response.status_code}): "
269
+ f"{response.text[:200]}"
270
+ )
271
+
272
+ if response.status_code != 200 or not data.get("success", False):
273
+ raise KiboNetAPIError(
274
+ data.get("message", f"API error (HTTP {response.status_code})"),
275
+ code=data.get("code"),
276
+ response_data=data,
277
+ )
278
+
279
+ return data
@@ -0,0 +1,47 @@
1
+ """
2
+ Custom exception hierarchy for KiboNet SMS SDK.
3
+ All exceptions inherit from KiboNetError for easy catching.
4
+ """
5
+
6
+
7
+ class KiboNetError(Exception):
8
+ """Base exception for all KiboNet SDK errors"""
9
+ pass
10
+
11
+
12
+ class KiboNetAuthenticationError(KiboNetError):
13
+ """Invalid API key or secret (HTTP 401)"""
14
+ pass
15
+
16
+
17
+ class KiboNetValidationError(KiboNetError):
18
+ """Input validation failed (e.g., invalid phone number, empty message)"""
19
+ pass
20
+
21
+
22
+ class KiboNetAPIError(KiboNetError):
23
+ """API returned an error response"""
24
+ def __init__(self, message: str, code: int = None, response_data: dict = None):
25
+ super().__init__(message)
26
+ self.code = code
27
+ self.response_data = response_data or {}
28
+
29
+
30
+ class KiboNetRateLimitError(KiboNetAPIError):
31
+ """Rate limit exceeded (HTTP 429)"""
32
+ pass
33
+
34
+
35
+ class KiboNetInsufficientBalanceError(KiboNetAPIError):
36
+ """Not enough SMS credits"""
37
+ pass
38
+
39
+
40
+ class KiboNetConnectionError(KiboNetError):
41
+ """Network-level error (timeout, DNS failure, TLS error)"""
42
+ pass
43
+
44
+
45
+ class KiboNetServerError(KiboNetAPIError):
46
+ """KiboNet server error (HTTP 5xx)"""
47
+ pass
File without changes
@@ -0,0 +1,108 @@
1
+ """
2
+ Asynchronous httpx client with connection pooling and retry logic.
3
+ """
4
+
5
+ import logging
6
+ from typing import Optional
7
+
8
+ import httpx
9
+
10
+ from ..exceptions import KiboNetConnectionError
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ BASE_URL = "https://sms.kibonet.co.tz"
15
+ SDK_VERSION = "0.1.0"
16
+
17
+
18
+ class AsyncHTTPClient:
19
+ """
20
+ Asynchronous httpx client with connection pooling and header-based auth.
21
+
22
+ Must be used as an async context manager or closed explicitly with
23
+ ``await client.aclose()``.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ api_key: str,
29
+ api_secret: str,
30
+ base_url: str = BASE_URL,
31
+ timeout: float = 30.0,
32
+ max_retries: int = 3,
33
+ logger: Optional[logging.Logger] = None,
34
+ ):
35
+ self.api_key = api_key
36
+ self.api_secret = api_secret
37
+ self.base_url = base_url.rstrip("/")
38
+ self.timeout = timeout
39
+ self.max_retries = max_retries
40
+ self.logger = logger or logging.getLogger(__name__)
41
+
42
+ transport = httpx.AsyncHTTPTransport(retries=max_retries)
43
+ self._client = httpx.AsyncClient(
44
+ base_url=self.base_url,
45
+ headers=self._build_headers(),
46
+ timeout=timeout,
47
+ transport=transport,
48
+ )
49
+ self.logger.debug(
50
+ "KiboNet async HTTP client initialised (base_url=%s, timeout=%s)",
51
+ self.base_url,
52
+ timeout,
53
+ )
54
+
55
+ def _build_headers(self) -> dict:
56
+ return {
57
+ "api_key": self.api_key,
58
+ "api_secret": self.api_secret,
59
+ "Content-Type": "application/json",
60
+ "Accept": "application/json",
61
+ "User-Agent": f"KiboNet-Python-SDK/{SDK_VERSION}",
62
+ }
63
+
64
+ async def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
65
+ """
66
+ Make an asynchronous HTTP request.
67
+
68
+ Args:
69
+ method: HTTP method (GET, POST, …)
70
+ endpoint: Path relative to base_url
71
+ **kwargs: Extra arguments forwarded to httpx
72
+
73
+ Returns:
74
+ httpx.Response
75
+
76
+ Raises:
77
+ KiboNetConnectionError: On any network-level failure
78
+ """
79
+ url = f"/{endpoint.lstrip('/')}"
80
+ self.logger.debug("→ %s %s", method, url)
81
+ try:
82
+ response = await self._client.request(method, url, **kwargs)
83
+ self.logger.debug("← %s", response.status_code)
84
+ return response
85
+ except httpx.TimeoutException as exc:
86
+ self.logger.error("Request timed out: %s", exc)
87
+ raise KiboNetConnectionError(
88
+ f"Request timed out after {self.timeout}s"
89
+ ) from exc
90
+ except httpx.ConnectError as exc:
91
+ self.logger.error("Connection error: %s", exc)
92
+ raise KiboNetConnectionError(
93
+ f"Failed to connect to KiboNet API: {exc}"
94
+ ) from exc
95
+ except httpx.RequestError as exc:
96
+ self.logger.error("Request failed: %s", exc)
97
+ raise KiboNetConnectionError(f"Request failed: {exc}") from exc
98
+
99
+ async def aclose(self) -> None:
100
+ """Close the underlying async HTTP client."""
101
+ await self._client.aclose()
102
+ self.logger.debug("KiboNet async HTTP client closed")
103
+
104
+ async def __aenter__(self):
105
+ return self
106
+
107
+ async def __aexit__(self, *args):
108
+ await self.aclose()
@@ -0,0 +1,108 @@
1
+ """
2
+ Synchronous httpx client with connection pooling and retry logic.
3
+ """
4
+
5
+ import logging
6
+ from typing import Optional, Any
7
+
8
+ import httpx
9
+
10
+ from ..exceptions import KiboNetConnectionError
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ BASE_URL = "https://sms.kibonet.co.tz"
15
+ SDK_VERSION = "0.1.0"
16
+
17
+
18
+ class HTTPClient:
19
+ """
20
+ Synchronous httpx client with connection pooling, retries, and header-based auth.
21
+
22
+ Designed to be reused across multiple requests. Use as a context manager
23
+ or call close() explicitly when done.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ api_key: str,
29
+ api_secret: str,
30
+ base_url: str = BASE_URL,
31
+ timeout: float = 30.0,
32
+ max_retries: int = 3,
33
+ logger: Optional[logging.Logger] = None,
34
+ ):
35
+ self.api_key = api_key
36
+ self.api_secret = api_secret
37
+ self.base_url = base_url.rstrip("/")
38
+ self.timeout = timeout
39
+ self.max_retries = max_retries
40
+ self.logger = logger or logging.getLogger(__name__)
41
+
42
+ transport = httpx.HTTPTransport(retries=max_retries)
43
+ self._client = httpx.Client(
44
+ base_url=self.base_url,
45
+ headers=self._build_headers(),
46
+ timeout=timeout,
47
+ transport=transport,
48
+ )
49
+ self.logger.debug(
50
+ "KiboNet sync HTTP client initialised (base_url=%s, timeout=%s)",
51
+ self.base_url,
52
+ timeout,
53
+ )
54
+
55
+ def _build_headers(self) -> dict:
56
+ return {
57
+ "api_key": self.api_key,
58
+ "api_secret": self.api_secret,
59
+ "Content-Type": "application/json",
60
+ "Accept": "application/json",
61
+ "User-Agent": f"KiboNet-Python-SDK/{SDK_VERSION}",
62
+ }
63
+
64
+ def request(self, method: str, endpoint: str, **kwargs) -> httpx.Response:
65
+ """
66
+ Make an HTTP request.
67
+
68
+ Args:
69
+ method: HTTP method (GET, POST, …)
70
+ endpoint: Path relative to base_url
71
+ **kwargs: Extra arguments forwarded to httpx
72
+
73
+ Returns:
74
+ httpx.Response
75
+
76
+ Raises:
77
+ KiboNetConnectionError: On any network-level failure
78
+ """
79
+ url = f"/{endpoint.lstrip('/')}"
80
+ self.logger.debug("→ %s %s", method, url)
81
+ try:
82
+ response = self._client.request(method, url, **kwargs)
83
+ self.logger.debug("← %s", response.status_code)
84
+ return response
85
+ except httpx.TimeoutException as exc:
86
+ self.logger.error("Request timed out: %s", exc)
87
+ raise KiboNetConnectionError(
88
+ f"Request timed out after {self.timeout}s"
89
+ ) from exc
90
+ except httpx.ConnectError as exc:
91
+ self.logger.error("Connection error: %s", exc)
92
+ raise KiboNetConnectionError(
93
+ f"Failed to connect to KiboNet API: {exc}"
94
+ ) from exc
95
+ except httpx.RequestError as exc:
96
+ self.logger.error("Request failed: %s", exc)
97
+ raise KiboNetConnectionError(f"Request failed: {exc}") from exc
98
+
99
+ def close(self) -> None:
100
+ """Close the underlying HTTP client."""
101
+ self._client.close()
102
+ self.logger.debug("KiboNet sync HTTP client closed")
103
+
104
+ def __enter__(self):
105
+ return self
106
+
107
+ def __exit__(self, *args):
108
+ self.close()
@@ -0,0 +1,92 @@
1
+ """
2
+ Phone number validation utilities.
3
+
4
+ KiboNet accepts numbers in international format (e.g. 255XXXXXXXXX for Tanzania).
5
+ Local 0XXXXXXXXX format is automatically converted.
6
+ """
7
+
8
+ import re
9
+ from typing import List, Tuple, Union
10
+
11
+
12
+ class PhoneNumberValidator:
13
+ """Validator for phone numbers used with KiboNet SMS API."""
14
+
15
+ # Tanzania country code — most KiboNet customers are TZ-based
16
+ DEFAULT_COUNTRY_CODE = "255"
17
+ TZ_VALID_LENGTH = 12 # 255 + 9 digits
18
+
19
+ @classmethod
20
+ def _strip(cls, phone: str) -> str:
21
+ """Remove all non-digit characters (spaces, dashes, +)."""
22
+ return re.sub(r"\D", "", phone)
23
+
24
+ @classmethod
25
+ def validate(cls, phone: str) -> bool:
26
+ """
27
+ Return True if *phone* looks like a valid international number.
28
+
29
+ Accepts:
30
+ - Tanzanian format : 255XXXXXXXXX / 0XXXXXXXXX / +255XXXXXXXXX
31
+ - Any E.164 number : 1-15 digits with a non-zero leading digit
32
+ """
33
+ if not phone or not isinstance(phone, str):
34
+ return False
35
+ cleaned = cls._strip(phone)
36
+ if cleaned.startswith("0") and len(cleaned) == 10:
37
+ cleaned = cls.DEFAULT_COUNTRY_CODE + cleaned[1:]
38
+ # E.164: 7–15 digits, no leading zero after stripping
39
+ return bool(re.fullmatch(r"[1-9]\d{6,14}", cleaned))
40
+
41
+ @classmethod
42
+ def clean(cls, phone: str) -> str:
43
+ """
44
+ Clean and return phone in international format (no + prefix).
45
+
46
+ Raises:
47
+ ValueError: If the number is invalid.
48
+ """
49
+ if not phone:
50
+ raise ValueError("Phone number cannot be empty")
51
+ cleaned = cls._strip(phone)
52
+ if cleaned.startswith("0") and len(cleaned) == 10:
53
+ cleaned = cls.DEFAULT_COUNTRY_CODE + cleaned[1:]
54
+ if not cls.validate(cleaned):
55
+ raise ValueError(
56
+ f"Invalid phone number: '{phone}'. "
57
+ "Expected international format e.g. 255712345678"
58
+ )
59
+ return cleaned
60
+
61
+ @classmethod
62
+ def validate_batch(cls, phones: List[str]) -> List[Tuple[str, bool]]:
63
+ """Validate a list of numbers, returning (phone, is_valid) tuples."""
64
+ return [(p, cls.validate(p)) for p in phones]
65
+
66
+ @classmethod
67
+ def clean_batch(cls, phones: List[str]) -> List[str]:
68
+ """
69
+ Clean a list of phone numbers.
70
+
71
+ Raises:
72
+ ValueError: If any number is invalid.
73
+ """
74
+ return [cls.clean(p) for p in phones]
75
+
76
+ @classmethod
77
+ def to_csv(cls, phones: Union[str, List[str]]) -> str:
78
+ """
79
+ Convert a string or list of phone numbers to the comma-separated
80
+ format required by KiboNet's ``contacts`` field.
81
+
82
+ All numbers are validated and cleaned first.
83
+
84
+ Raises:
85
+ ValueError: If any number is invalid.
86
+ """
87
+ if isinstance(phones, str):
88
+ # May already be CSV — split, clean, rejoin
89
+ phones = [p.strip() for p in phones.split(",") if p.strip()]
90
+ if not phones:
91
+ raise ValueError("At least one recipient phone number is required")
92
+ return ",".join(cls.clean_batch(phones))