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 ADDED
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .coverage
10
+ .env
11
+ .DS_Store
@@ -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)