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.
- kibonet_python_sdk-0.1.0.dist-info/METADATA +269 -0
- kibonet_python_sdk-0.1.0.dist-info/RECORD +13 -0
- kibonet_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- kibonet_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- kibonet_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
- kibonet_sdk/__init__.py +61 -0
- kibonet_sdk/async_client.py +238 -0
- kibonet_sdk/client.py +279 -0
- kibonet_sdk/exceptions.py +47 -0
- kibonet_sdk/utils/__init__.py +0 -0
- kibonet_sdk/utils/async_http_client.py +108 -0
- kibonet_sdk/utils/http_client.py +108 -0
- kibonet_sdk/utils/validators.py +92 -0
|
@@ -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
|
+
[](https://pypi.org/project/kibonet-python-sdk/)
|
|
44
|
+
[](https://pypi.org/project/kibonet-python-sdk/)
|
|
45
|
+
[](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,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
|
kibonet_sdk/__init__.py
ADDED
|
@@ -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))
|