mista 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mista-0.1.0/.gitignore +11 -0
- mista-0.1.0/CHANGELOG.md +15 -0
- mista-0.1.0/LICENSE +21 -0
- mista-0.1.0/PKG-INFO +235 -0
- mista-0.1.0/README.md +207 -0
- mista-0.1.0/pyproject.toml +55 -0
- mista-0.1.0/src/mista/__init__.py +44 -0
- mista-0.1.0/src/mista/_base.py +93 -0
- mista-0.1.0/src/mista/_client.py +162 -0
- mista-0.1.0/src/mista/_errors.py +130 -0
- mista-0.1.0/src/mista/_operations.py +305 -0
- mista-0.1.0/src/mista/_resources.py +456 -0
- mista-0.1.0/src/mista/_version.py +1 -0
- mista-0.1.0/src/mista/pagination.py +95 -0
- mista-0.1.0/src/mista/py.typed +0 -0
- mista-0.1.0/src/mista/types.py +153 -0
- mista-0.1.0/tests/__init__.py +0 -0
- mista-0.1.0/tests/conftest.py +82 -0
- mista-0.1.0/tests/test_async.py +57 -0
- mista-0.1.0/tests/test_client.py +147 -0
- mista-0.1.0/tests/test_resources.py +221 -0
mista-0.1.0/.gitignore
ADDED
mista-0.1.0/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First release, covering the Mista API v3 as documented at https://docs.mista.io,
|
|
6
|
+
with a synchronous `Mista` and an asynchronous `AsyncMista` client:
|
|
7
|
+
|
|
8
|
+
- SMS: `sms.send`
|
|
9
|
+
- Campaigns: `campaigns.bulk`, `campaigns.send_to_groups`, `campaigns.get`
|
|
10
|
+
- Logs: `logs.list` (filters + auto-pagination), `logs.get`
|
|
11
|
+
- Account: `account.balance`, `account.me`
|
|
12
|
+
- Contact groups and contacts: list, create, get, update, delete
|
|
13
|
+
- Verify: `verify.start`, `verify.check`, `verify.get`
|
|
14
|
+
- Voice: `voice.access_token`, `voice.numbers`, `voice.calls.list`, `voice.calls.get`
|
|
15
|
+
- Typed errors, automatic retries for rate limits, request timeouts, `py.typed`
|
mista-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mista
|
|
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.
|
mista-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mista
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Mista Messaging, Verify and Voice APIs
|
|
5
|
+
Project-URL: Homepage, https://mista.io
|
|
6
|
+
Project-URL: Documentation, https://docs.mista.io
|
|
7
|
+
Project-URL: Source, https://github.com/mista-io/mista-python
|
|
8
|
+
Project-URL: Issues, https://github.com/mista-io/mista-python/issues
|
|
9
|
+
Author: Mista
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: africa,bulk-sms,mista,otp,rwanda,sms,verify,voice
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Communications :: Telephony
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: httpx<1,>=0.25
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
23
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
26
|
+
Requires-Dist: twine>=5; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# Mista Python SDK
|
|
30
|
+
|
|
31
|
+
Official Python client for the [Mista](https://mista.io) Messaging, Verify and Voice APIs.
|
|
32
|
+
Full API reference: https://docs.mista.io
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install mista
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Requires Python 3.9+. The only dependency is [httpx](https://www.python-httpx.org/).
|
|
39
|
+
Both a synchronous (`Mista`) and an asynchronous (`AsyncMista`) client are included.
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from mista import Mista
|
|
45
|
+
|
|
46
|
+
mista = Mista(token="...") # or set MISTA_API_TOKEN
|
|
47
|
+
|
|
48
|
+
message = mista.sms.send(to="250780000001", sender_id="YourBrand", message="Your order has shipped")
|
|
49
|
+
print(message["uid"], message["status"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Get your API token in the dashboard under **Settings → API**. Responses are plain dicts with the
|
|
53
|
+
same snake_case keys as the docs (typed as `TypedDict`s in `mista.types`).
|
|
54
|
+
|
|
55
|
+
Async:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import asyncio
|
|
59
|
+
from mista import AsyncMista
|
|
60
|
+
|
|
61
|
+
async def main() -> None:
|
|
62
|
+
async with AsyncMista() as mista:
|
|
63
|
+
balance = await mista.account.balance()
|
|
64
|
+
print(balance["remaining_unit"])
|
|
65
|
+
|
|
66
|
+
asyncio.run(main())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Every method below works the same way on `AsyncMista`; just `await` it.
|
|
70
|
+
|
|
71
|
+
## SMS
|
|
72
|
+
|
|
73
|
+
`sms.send` sends one message to **one** recipient. To reach several numbers, or to schedule a
|
|
74
|
+
send, use a campaign.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
message = mista.sms.send(
|
|
78
|
+
to="250780000001",
|
|
79
|
+
sender_id="YourBrand",
|
|
80
|
+
message="Hello",
|
|
81
|
+
type="plain", # plain | unicode | voice | mms | whatsapp | viber | otp
|
|
82
|
+
)
|
|
83
|
+
latest = mista.logs.get(message["uid"]) # delivery status
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Campaigns
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from datetime import datetime
|
|
90
|
+
|
|
91
|
+
# Broadcast: one message, up to 10,000 numbers
|
|
92
|
+
mista.campaigns.bulk(
|
|
93
|
+
sender_id="LOYALTY",
|
|
94
|
+
recipients=["250780000001", "250780000002"],
|
|
95
|
+
message="Double points this weekend!",
|
|
96
|
+
schedule_time=datetime(2026, 12, 24, 9, 0), # or "2026-12-24 09:00"; account timezone
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Personalized: one message per number
|
|
100
|
+
mista.campaigns.bulk(
|
|
101
|
+
sender_id="LOYALTY",
|
|
102
|
+
recipients=[
|
|
103
|
+
{"to": "250780000001", "message": "Hi Alice, you have 120 points."},
|
|
104
|
+
{"to": "250780000002", "message": "Hi Bob, you have 45 points."},
|
|
105
|
+
],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Everyone in one or more contact groups
|
|
109
|
+
mista.campaigns.send_to_groups(group_uids=["grp_uid"], sender_id="YourBrand", message="Hi!")
|
|
110
|
+
|
|
111
|
+
campaign = mista.campaigns.get("campaign_uid")
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Message logs
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
page = mista.logs.list(start_date="2026-10-01", status="Delivered", per_page=50)
|
|
118
|
+
page.items # this page
|
|
119
|
+
page.meta.total # total matches
|
|
120
|
+
|
|
121
|
+
for message in page: # walks every remaining page (use `async for` with AsyncMista)
|
|
122
|
+
print(message["uid"], message["status"])
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Filters: `page`, `per_page`, `start_date`, `end_date` (`Y-m-d`), `sender_id`, `status`, `sms_type`.
|
|
126
|
+
When nothing matches, you get an empty page.
|
|
127
|
+
|
|
128
|
+
## Account
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
balance = mista.account.balance() # {"remaining_unit": ..., "expired_on": ...}
|
|
132
|
+
me = mista.account.me()
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Contact groups and contacts
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
group = mista.contact_groups.create("Developers")
|
|
139
|
+
mista.contact_groups.list()
|
|
140
|
+
mista.contact_groups.get(group["uid"])
|
|
141
|
+
mista.contact_groups.update(group["uid"], "Developers KGL")
|
|
142
|
+
|
|
143
|
+
contact = mista.contacts.create(
|
|
144
|
+
group["uid"],
|
|
145
|
+
phone="250780000001",
|
|
146
|
+
first_name="Alice",
|
|
147
|
+
last_name="Uwase",
|
|
148
|
+
fields={"CITY": "Kigali"}, # custom fields, keyed by the group's field tag
|
|
149
|
+
)
|
|
150
|
+
mista.contacts.list(group["uid"])
|
|
151
|
+
mista.contacts.get(group["uid"], contact["uid"])
|
|
152
|
+
mista.contacts.update(group["uid"], contact["uid"], phone="250780000001", first_name="Alicia")
|
|
153
|
+
mista.contacts.delete(group["uid"], contact["uid"])
|
|
154
|
+
|
|
155
|
+
mista.contact_groups.delete(group["uid"]) # also deletes its contacts
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Verify (OTP)
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
verification = mista.verify.start(to="+250780000001", channel="sms")
|
|
162
|
+
|
|
163
|
+
result = mista.verify.check(sid=verification["sid"], code="123456")
|
|
164
|
+
if result["verified"]:
|
|
165
|
+
... # signed in
|
|
166
|
+
else:
|
|
167
|
+
print(result["reason"]) # e.g. "invalid_code"; a wrong code does not raise
|
|
168
|
+
|
|
169
|
+
mista.verify.get(verification["sid"])
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Voice
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
token = mista.voice.access_token(platform="ios")["token"]
|
|
176
|
+
numbers = mista.voice.numbers()
|
|
177
|
+
calls = mista.voice.calls.list(filter="missed", per_page=20)
|
|
178
|
+
call = mista.voice.calls.get("call_uid")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Errors
|
|
182
|
+
|
|
183
|
+
Every failure raises a subclass of `mista.MistaError`:
|
|
184
|
+
|
|
185
|
+
| Exception | When |
|
|
186
|
+
| --- | --- |
|
|
187
|
+
| `BadRequestError` | 400, e.g. an invalid phone number |
|
|
188
|
+
| `AuthenticationError` | 401, missing or wrong token |
|
|
189
|
+
| `PermissionDeniedError` | 403 |
|
|
190
|
+
| `NotFoundError` | 404 |
|
|
191
|
+
| `ValidationError` | 422; field problems are in `error.errors` |
|
|
192
|
+
| `RateLimitError` | 429 after retries; see `error.retry_after` |
|
|
193
|
+
| `ServerError` | 5xx |
|
|
194
|
+
| `APIError` | any other API error, including a 200 whose body says `"status": "error"` (e.g. a contact already in the group) |
|
|
195
|
+
| `APIConnectionError` / `APITimeoutError` | network failure or timeout |
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from mista import ValidationError
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
mista.sms.send(to="123", sender_id="YourBrand", message="Hi")
|
|
202
|
+
except ValidationError as error:
|
|
203
|
+
for problem in error.errors:
|
|
204
|
+
print(problem.field, problem.message)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
All API errors carry `status`, `body` (the parsed response) and `headers`.
|
|
208
|
+
|
|
209
|
+
## Retries, timeouts and HTTP client
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
mista = Mista(max_retries=2, timeout=30.0)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- `429 Too Many Requests` is retried for every request, waiting for `Retry-After`.
|
|
216
|
+
- Network errors and 5xx responses are retried for `GET` only, so a send is never duplicated.
|
|
217
|
+
- `max_retries=0` turns retries off.
|
|
218
|
+
- Pass `http_client=httpx.Client(...)` (or `httpx.AsyncClient`) for proxies or custom transports.
|
|
219
|
+
- Use the client as a context manager, or call `close()` / `await aclose()`, to release connections.
|
|
220
|
+
|
|
221
|
+
## Not covered
|
|
222
|
+
|
|
223
|
+
Delivery-report webhooks (configure those in the dashboard) and the retired Push API.
|
|
224
|
+
|
|
225
|
+
## Development
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
229
|
+
.venv/bin/pytest && .venv/bin/mypy
|
|
230
|
+
MISTA_API_TOKEN=... .venv/bin/python scripts/smoke.py # read-only: balance + account
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## License
|
|
234
|
+
|
|
235
|
+
MIT
|
mista-0.1.0/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Mista Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python client for the [Mista](https://mista.io) Messaging, Verify and Voice APIs.
|
|
4
|
+
Full API reference: https://docs.mista.io
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install mista
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Requires Python 3.9+. The only dependency is [httpx](https://www.python-httpx.org/).
|
|
11
|
+
Both a synchronous (`Mista`) and an asynchronous (`AsyncMista`) client are included.
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from mista import Mista
|
|
17
|
+
|
|
18
|
+
mista = Mista(token="...") # or set MISTA_API_TOKEN
|
|
19
|
+
|
|
20
|
+
message = mista.sms.send(to="250780000001", sender_id="YourBrand", message="Your order has shipped")
|
|
21
|
+
print(message["uid"], message["status"])
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Get your API token in the dashboard under **Settings → API**. Responses are plain dicts with the
|
|
25
|
+
same snake_case keys as the docs (typed as `TypedDict`s in `mista.types`).
|
|
26
|
+
|
|
27
|
+
Async:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import asyncio
|
|
31
|
+
from mista import AsyncMista
|
|
32
|
+
|
|
33
|
+
async def main() -> None:
|
|
34
|
+
async with AsyncMista() as mista:
|
|
35
|
+
balance = await mista.account.balance()
|
|
36
|
+
print(balance["remaining_unit"])
|
|
37
|
+
|
|
38
|
+
asyncio.run(main())
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Every method below works the same way on `AsyncMista`; just `await` it.
|
|
42
|
+
|
|
43
|
+
## SMS
|
|
44
|
+
|
|
45
|
+
`sms.send` sends one message to **one** recipient. To reach several numbers, or to schedule a
|
|
46
|
+
send, use a campaign.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
message = mista.sms.send(
|
|
50
|
+
to="250780000001",
|
|
51
|
+
sender_id="YourBrand",
|
|
52
|
+
message="Hello",
|
|
53
|
+
type="plain", # plain | unicode | voice | mms | whatsapp | viber | otp
|
|
54
|
+
)
|
|
55
|
+
latest = mista.logs.get(message["uid"]) # delivery status
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Campaigns
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from datetime import datetime
|
|
62
|
+
|
|
63
|
+
# Broadcast: one message, up to 10,000 numbers
|
|
64
|
+
mista.campaigns.bulk(
|
|
65
|
+
sender_id="LOYALTY",
|
|
66
|
+
recipients=["250780000001", "250780000002"],
|
|
67
|
+
message="Double points this weekend!",
|
|
68
|
+
schedule_time=datetime(2026, 12, 24, 9, 0), # or "2026-12-24 09:00"; account timezone
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Personalized: one message per number
|
|
72
|
+
mista.campaigns.bulk(
|
|
73
|
+
sender_id="LOYALTY",
|
|
74
|
+
recipients=[
|
|
75
|
+
{"to": "250780000001", "message": "Hi Alice, you have 120 points."},
|
|
76
|
+
{"to": "250780000002", "message": "Hi Bob, you have 45 points."},
|
|
77
|
+
],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Everyone in one or more contact groups
|
|
81
|
+
mista.campaigns.send_to_groups(group_uids=["grp_uid"], sender_id="YourBrand", message="Hi!")
|
|
82
|
+
|
|
83
|
+
campaign = mista.campaigns.get("campaign_uid")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Message logs
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
page = mista.logs.list(start_date="2026-10-01", status="Delivered", per_page=50)
|
|
90
|
+
page.items # this page
|
|
91
|
+
page.meta.total # total matches
|
|
92
|
+
|
|
93
|
+
for message in page: # walks every remaining page (use `async for` with AsyncMista)
|
|
94
|
+
print(message["uid"], message["status"])
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Filters: `page`, `per_page`, `start_date`, `end_date` (`Y-m-d`), `sender_id`, `status`, `sms_type`.
|
|
98
|
+
When nothing matches, you get an empty page.
|
|
99
|
+
|
|
100
|
+
## Account
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
balance = mista.account.balance() # {"remaining_unit": ..., "expired_on": ...}
|
|
104
|
+
me = mista.account.me()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Contact groups and contacts
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
group = mista.contact_groups.create("Developers")
|
|
111
|
+
mista.contact_groups.list()
|
|
112
|
+
mista.contact_groups.get(group["uid"])
|
|
113
|
+
mista.contact_groups.update(group["uid"], "Developers KGL")
|
|
114
|
+
|
|
115
|
+
contact = mista.contacts.create(
|
|
116
|
+
group["uid"],
|
|
117
|
+
phone="250780000001",
|
|
118
|
+
first_name="Alice",
|
|
119
|
+
last_name="Uwase",
|
|
120
|
+
fields={"CITY": "Kigali"}, # custom fields, keyed by the group's field tag
|
|
121
|
+
)
|
|
122
|
+
mista.contacts.list(group["uid"])
|
|
123
|
+
mista.contacts.get(group["uid"], contact["uid"])
|
|
124
|
+
mista.contacts.update(group["uid"], contact["uid"], phone="250780000001", first_name="Alicia")
|
|
125
|
+
mista.contacts.delete(group["uid"], contact["uid"])
|
|
126
|
+
|
|
127
|
+
mista.contact_groups.delete(group["uid"]) # also deletes its contacts
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Verify (OTP)
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
verification = mista.verify.start(to="+250780000001", channel="sms")
|
|
134
|
+
|
|
135
|
+
result = mista.verify.check(sid=verification["sid"], code="123456")
|
|
136
|
+
if result["verified"]:
|
|
137
|
+
... # signed in
|
|
138
|
+
else:
|
|
139
|
+
print(result["reason"]) # e.g. "invalid_code"; a wrong code does not raise
|
|
140
|
+
|
|
141
|
+
mista.verify.get(verification["sid"])
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Voice
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
token = mista.voice.access_token(platform="ios")["token"]
|
|
148
|
+
numbers = mista.voice.numbers()
|
|
149
|
+
calls = mista.voice.calls.list(filter="missed", per_page=20)
|
|
150
|
+
call = mista.voice.calls.get("call_uid")
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Errors
|
|
154
|
+
|
|
155
|
+
Every failure raises a subclass of `mista.MistaError`:
|
|
156
|
+
|
|
157
|
+
| Exception | When |
|
|
158
|
+
| --- | --- |
|
|
159
|
+
| `BadRequestError` | 400, e.g. an invalid phone number |
|
|
160
|
+
| `AuthenticationError` | 401, missing or wrong token |
|
|
161
|
+
| `PermissionDeniedError` | 403 |
|
|
162
|
+
| `NotFoundError` | 404 |
|
|
163
|
+
| `ValidationError` | 422; field problems are in `error.errors` |
|
|
164
|
+
| `RateLimitError` | 429 after retries; see `error.retry_after` |
|
|
165
|
+
| `ServerError` | 5xx |
|
|
166
|
+
| `APIError` | any other API error, including a 200 whose body says `"status": "error"` (e.g. a contact already in the group) |
|
|
167
|
+
| `APIConnectionError` / `APITimeoutError` | network failure or timeout |
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from mista import ValidationError
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
mista.sms.send(to="123", sender_id="YourBrand", message="Hi")
|
|
174
|
+
except ValidationError as error:
|
|
175
|
+
for problem in error.errors:
|
|
176
|
+
print(problem.field, problem.message)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
All API errors carry `status`, `body` (the parsed response) and `headers`.
|
|
180
|
+
|
|
181
|
+
## Retries, timeouts and HTTP client
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
mista = Mista(max_retries=2, timeout=30.0)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- `429 Too Many Requests` is retried for every request, waiting for `Retry-After`.
|
|
188
|
+
- Network errors and 5xx responses are retried for `GET` only, so a send is never duplicated.
|
|
189
|
+
- `max_retries=0` turns retries off.
|
|
190
|
+
- Pass `http_client=httpx.Client(...)` (or `httpx.AsyncClient`) for proxies or custom transports.
|
|
191
|
+
- Use the client as a context manager, or call `close()` / `await aclose()`, to release connections.
|
|
192
|
+
|
|
193
|
+
## Not covered
|
|
194
|
+
|
|
195
|
+
Delivery-report webhooks (configure those in the dashboard) and the retired Push API.
|
|
196
|
+
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
201
|
+
.venv/bin/pytest && .venv/bin/mypy
|
|
202
|
+
MISTA_API_TOKEN=... .venv/bin/python scripts/smoke.py # read-only: balance + account
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mista"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python SDK for the Mista Messaging, Verify and Voice APIs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "Mista" }]
|
|
14
|
+
keywords = ["mista", "sms", "bulk-sms", "otp", "verify", "voice", "rwanda", "africa"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Communications :: Telephony",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["httpx>=0.25,<1"]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "mypy>=1.10", "build>=1.2", "twine>=5"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://mista.io"
|
|
30
|
+
Documentation = "https://docs.mista.io"
|
|
31
|
+
Source = "https://github.com/mista-io/mista-python"
|
|
32
|
+
Issues = "https://github.com/mista-io/mista-python/issues"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.version]
|
|
35
|
+
path = "src/mista/_version.py"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/mista"]
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
include = ["src/mista", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
asyncio_mode = "auto"
|
|
46
|
+
|
|
47
|
+
[tool.mypy]
|
|
48
|
+
strict = true
|
|
49
|
+
python_version = "3.10"
|
|
50
|
+
files = ["src/mista", "tests", "examples"]
|
|
51
|
+
|
|
52
|
+
[[tool.mypy.overrides]]
|
|
53
|
+
module = ["tests.*", "examples.*"]
|
|
54
|
+
disallow_untyped_defs = false
|
|
55
|
+
disallow_incomplete_defs = false
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Official Python SDK for the Mista Messaging, Verify and Voice APIs. Docs: https://docs.mista.io"""
|
|
2
|
+
|
|
3
|
+
from ._base import DEFAULT_BASE_URL
|
|
4
|
+
from ._client import AsyncMista, Mista
|
|
5
|
+
from ._errors import (
|
|
6
|
+
APIConnectionError,
|
|
7
|
+
APIError,
|
|
8
|
+
APITimeoutError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
BadRequestError,
|
|
11
|
+
FieldError,
|
|
12
|
+
MistaError,
|
|
13
|
+
NotFoundError,
|
|
14
|
+
PermissionDeniedError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
ServerError,
|
|
17
|
+
ValidationError,
|
|
18
|
+
)
|
|
19
|
+
from ._operations import MAX_BULK_RECIPIENTS
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
from .pagination import AsyncPage, Page, PageMeta
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Mista",
|
|
25
|
+
"AsyncMista",
|
|
26
|
+
"DEFAULT_BASE_URL",
|
|
27
|
+
"MAX_BULK_RECIPIENTS",
|
|
28
|
+
"Page",
|
|
29
|
+
"AsyncPage",
|
|
30
|
+
"PageMeta",
|
|
31
|
+
"MistaError",
|
|
32
|
+
"APIError",
|
|
33
|
+
"BadRequestError",
|
|
34
|
+
"AuthenticationError",
|
|
35
|
+
"PermissionDeniedError",
|
|
36
|
+
"NotFoundError",
|
|
37
|
+
"ValidationError",
|
|
38
|
+
"RateLimitError",
|
|
39
|
+
"ServerError",
|
|
40
|
+
"APIConnectionError",
|
|
41
|
+
"APITimeoutError",
|
|
42
|
+
"FieldError",
|
|
43
|
+
"__version__",
|
|
44
|
+
]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import random
|
|
5
|
+
from typing import Any, Dict, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._errors import MistaError, error_from_response
|
|
10
|
+
from ._operations import Request
|
|
11
|
+
from ._version import __version__
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.mista.io"
|
|
14
|
+
DEFAULT_TIMEOUT = 30.0
|
|
15
|
+
DEFAULT_MAX_RETRIES = 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseClient:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
token: Optional[str],
|
|
22
|
+
base_url: Optional[str],
|
|
23
|
+
timeout: float,
|
|
24
|
+
max_retries: int,
|
|
25
|
+
) -> None:
|
|
26
|
+
token = token or os.environ.get("MISTA_API_TOKEN")
|
|
27
|
+
if not token:
|
|
28
|
+
raise MistaError("Missing API token. Pass token=... or set the MISTA_API_TOKEN environment variable.")
|
|
29
|
+
self._token = token
|
|
30
|
+
self.base_url = (base_url or os.environ.get("MISTA_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
self.max_retries = max_retries
|
|
33
|
+
|
|
34
|
+
def _headers(self, request: Request) -> Dict[str, str]:
|
|
35
|
+
headers = {
|
|
36
|
+
"Authorization": f"Bearer {self._token}",
|
|
37
|
+
"Accept": "application/json",
|
|
38
|
+
"User-Agent": f"mista-python/{__version__}",
|
|
39
|
+
}
|
|
40
|
+
if request.body is not None:
|
|
41
|
+
headers["Content-Type"] = "application/json"
|
|
42
|
+
return headers
|
|
43
|
+
|
|
44
|
+
def _build(self, request: Request) -> Dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"method": request.method,
|
|
47
|
+
"url": f"{self.base_url}{request.path}",
|
|
48
|
+
"params": {k: v for k, v in request.query.items() if v is not None and v != ""},
|
|
49
|
+
"json": request.body,
|
|
50
|
+
"headers": self._headers(request),
|
|
51
|
+
"timeout": self.timeout,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def _should_retry_status(self, request: Request, status: int, attempt: int) -> bool:
|
|
55
|
+
if attempt >= self.max_retries:
|
|
56
|
+
return False
|
|
57
|
+
return status == 429 or (status >= 500 and request.method == "GET")
|
|
58
|
+
|
|
59
|
+
def _should_retry_connection(self, request: Request, attempt: int) -> bool:
|
|
60
|
+
return request.method == "GET" and attempt < self.max_retries
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _backoff(attempt: int) -> float:
|
|
64
|
+
base = min(8.0, 0.5 * 2.0**attempt)
|
|
65
|
+
return base / 2 + random.random() * (base / 2)
|
|
66
|
+
|
|
67
|
+
def _retry_delay(self, attempt: int, headers: Mapping[str, str]) -> float:
|
|
68
|
+
try:
|
|
69
|
+
retry_after = float(headers.get("retry-after", ""))
|
|
70
|
+
except ValueError:
|
|
71
|
+
retry_after = 0
|
|
72
|
+
if retry_after > 0:
|
|
73
|
+
return min(retry_after, 60.0)
|
|
74
|
+
return self._backoff(attempt)
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def _parse_body(response: httpx.Response) -> Any:
|
|
78
|
+
if not response.content:
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
return response.json()
|
|
82
|
+
except ValueError:
|
|
83
|
+
return response.text
|
|
84
|
+
|
|
85
|
+
def _result(self, response: httpx.Response) -> Any:
|
|
86
|
+
"""Return the envelope's ``data``, or raise the matching APIError."""
|
|
87
|
+
body = self._parse_body(response)
|
|
88
|
+
is_error = isinstance(body, dict) and body.get("status") == "error"
|
|
89
|
+
if response.is_success and not is_error:
|
|
90
|
+
if isinstance(body, dict) and body.get("status") == "success" and "data" in body:
|
|
91
|
+
return body["data"]
|
|
92
|
+
return body
|
|
93
|
+
raise error_from_response(response.status_code, body, response.headers)
|