voicetel-api 2.2.10__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.
- voicetel/__init__.py +46 -0
- voicetel/_http.py +265 -0
- voicetel/_version.py +1 -0
- voicetel/auth.py +57 -0
- voicetel/client.py +154 -0
- voicetel/exceptions.py +88 -0
- voicetel/models/__init__.py +352 -0
- voicetel/models/_base.py +37 -0
- voicetel/models/account.py +254 -0
- voicetel/models/acl.py +66 -0
- voicetel/models/authentication.py +69 -0
- voicetel/models/common.py +34 -0
- voicetel/models/e911.py +97 -0
- voicetel/models/gateways.py +84 -0
- voicetel/models/inumbering.py +241 -0
- voicetel/models/lookups.py +43 -0
- voicetel/models/messaging.py +164 -0
- voicetel/models/numbers.py +327 -0
- voicetel/models/support.py +265 -0
- voicetel/py.typed +0 -0
- voicetel/resources/__init__.py +38 -0
- voicetel/resources/_base.py +29 -0
- voicetel/resources/account.py +148 -0
- voicetel/resources/acl.py +36 -0
- voicetel/resources/authentication.py +26 -0
- voicetel/resources/e911.py +72 -0
- voicetel/resources/gateways.py +77 -0
- voicetel/resources/inumbering.py +152 -0
- voicetel/resources/lookups.py +29 -0
- voicetel/resources/messaging.py +132 -0
- voicetel/resources/numbers.py +394 -0
- voicetel/resources/support.py +108 -0
- voicetel_api-2.2.10.dist-info/METADATA +300 -0
- voicetel_api-2.2.10.dist-info/RECORD +36 -0
- voicetel_api-2.2.10.dist-info/WHEEL +4 -0
- voicetel_api-2.2.10.dist-info/licenses/LICENSE +21 -0
voicetel/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Official Python SDK for the VoiceTel REST API.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
from voicetel import Client
|
|
6
|
+
|
|
7
|
+
with Client() as c:
|
|
8
|
+
c.login(username=1234567890, password="...")
|
|
9
|
+
for n in c.numbers.list():
|
|
10
|
+
print(n["number"])
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from . import models
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .client import AsyncClient, Client
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
ApiError,
|
|
20
|
+
AuthenticationError,
|
|
21
|
+
BadRequestError,
|
|
22
|
+
ConfigurationError,
|
|
23
|
+
ConflictError,
|
|
24
|
+
NotFoundError,
|
|
25
|
+
PermissionDeniedError,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
ServerError,
|
|
28
|
+
VoiceTelError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"ApiError",
|
|
33
|
+
"AsyncClient",
|
|
34
|
+
"AuthenticationError",
|
|
35
|
+
"BadRequestError",
|
|
36
|
+
"Client",
|
|
37
|
+
"ConfigurationError",
|
|
38
|
+
"ConflictError",
|
|
39
|
+
"NotFoundError",
|
|
40
|
+
"PermissionDeniedError",
|
|
41
|
+
"RateLimitError",
|
|
42
|
+
"ServerError",
|
|
43
|
+
"VoiceTelError",
|
|
44
|
+
"__version__",
|
|
45
|
+
"models",
|
|
46
|
+
]
|
voicetel/_http.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .exceptions import ApiError, ConfigurationError, from_response
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.voicetel.com"
|
|
14
|
+
DEFAULT_TIMEOUT = 30.0
|
|
15
|
+
DEFAULT_MAX_RETRIES = 2
|
|
16
|
+
DEFAULT_USER_AGENT = f"voicetel-python/{__version__} (+https://github.com/voicetel/python-sdk)"
|
|
17
|
+
|
|
18
|
+
_RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Transport:
|
|
22
|
+
"""Sync HTTP transport. Owns an :class:`httpx.Client` and the retry/auth policy.
|
|
23
|
+
|
|
24
|
+
The Bearer token is mutable: :meth:`set_bearer` lets the auth flow swap it in after the
|
|
25
|
+
api-key exchange runs.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
api_key: str | None = None,
|
|
32
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
33
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
34
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
35
|
+
http_client: httpx.Client | None = None,
|
|
36
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
37
|
+
) -> None:
|
|
38
|
+
if max_retries < 0:
|
|
39
|
+
raise ConfigurationError("max_retries must be >= 0")
|
|
40
|
+
self._api_key = api_key
|
|
41
|
+
self._base_url = base_url.rstrip("/")
|
|
42
|
+
self._max_retries = max_retries
|
|
43
|
+
self._user_agent = user_agent
|
|
44
|
+
self._owns_client = http_client is None
|
|
45
|
+
self._http: httpx.Client = http_client or httpx.Client(
|
|
46
|
+
base_url=self._base_url, timeout=timeout
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def base_url(self) -> str:
|
|
51
|
+
return self._base_url
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def api_key(self) -> str | None:
|
|
55
|
+
return self._api_key
|
|
56
|
+
|
|
57
|
+
def set_bearer(self, api_key: str) -> None:
|
|
58
|
+
self._api_key = api_key
|
|
59
|
+
|
|
60
|
+
def request(
|
|
61
|
+
self,
|
|
62
|
+
method: str,
|
|
63
|
+
path: str,
|
|
64
|
+
*,
|
|
65
|
+
params: dict[str, Any] | None = None,
|
|
66
|
+
json: Any = None,
|
|
67
|
+
require_auth: bool = True,
|
|
68
|
+
) -> Any:
|
|
69
|
+
headers = self._headers(require_auth=require_auth)
|
|
70
|
+
if method.upper() in ("POST", "PUT", "PATCH"):
|
|
71
|
+
headers["Idempotency-Key"] = str(uuid.uuid4())
|
|
72
|
+
last_exc: Exception | None = None
|
|
73
|
+
for attempt in range(self._max_retries + 1):
|
|
74
|
+
try:
|
|
75
|
+
response = self._http.request(
|
|
76
|
+
method,
|
|
77
|
+
path,
|
|
78
|
+
params=_clean_params(params),
|
|
79
|
+
json=json,
|
|
80
|
+
headers=headers,
|
|
81
|
+
)
|
|
82
|
+
except httpx.TransportError as exc:
|
|
83
|
+
last_exc = exc
|
|
84
|
+
if attempt >= self._max_retries:
|
|
85
|
+
raise ApiError(
|
|
86
|
+
f"transport error after {attempt + 1} attempts: {exc}",
|
|
87
|
+
status_code=0,
|
|
88
|
+
) from exc
|
|
89
|
+
_sleep_backoff(attempt)
|
|
90
|
+
continue
|
|
91
|
+
if response.status_code in _RETRYABLE_STATUSES and attempt < self._max_retries:
|
|
92
|
+
_sleep_backoff(attempt, response)
|
|
93
|
+
continue
|
|
94
|
+
return _parse(response)
|
|
95
|
+
# unreachable: the loop always returns or raises
|
|
96
|
+
assert last_exc is not None # pragma: no cover
|
|
97
|
+
raise last_exc # pragma: no cover
|
|
98
|
+
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
if self._owns_client:
|
|
101
|
+
self._http.close()
|
|
102
|
+
|
|
103
|
+
def __enter__(self) -> Transport:
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def __exit__(self, *_: Any) -> None:
|
|
107
|
+
self.close()
|
|
108
|
+
|
|
109
|
+
def _headers(self, *, require_auth: bool) -> dict[str, str]:
|
|
110
|
+
headers = {
|
|
111
|
+
"User-Agent": self._user_agent,
|
|
112
|
+
"Accept": "application/json",
|
|
113
|
+
"Accept-Encoding": "gzip",
|
|
114
|
+
}
|
|
115
|
+
if require_auth:
|
|
116
|
+
if not self._api_key:
|
|
117
|
+
raise ConfigurationError(
|
|
118
|
+
"no api_key set; pass api_key= to Client(...) or call client.login()"
|
|
119
|
+
)
|
|
120
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
121
|
+
return headers
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class AsyncTransport:
|
|
125
|
+
"""Async counterpart to :class:`Transport`. Same surface, awaitable :meth:`request`."""
|
|
126
|
+
|
|
127
|
+
def __init__(
|
|
128
|
+
self,
|
|
129
|
+
*,
|
|
130
|
+
api_key: str | None = None,
|
|
131
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
132
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
133
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
134
|
+
http_client: httpx.AsyncClient | None = None,
|
|
135
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
136
|
+
) -> None:
|
|
137
|
+
if max_retries < 0:
|
|
138
|
+
raise ConfigurationError("max_retries must be >= 0")
|
|
139
|
+
self._api_key = api_key
|
|
140
|
+
self._base_url = base_url.rstrip("/")
|
|
141
|
+
self._max_retries = max_retries
|
|
142
|
+
self._user_agent = user_agent
|
|
143
|
+
self._owns_client = http_client is None
|
|
144
|
+
self._http: httpx.AsyncClient = http_client or httpx.AsyncClient(
|
|
145
|
+
base_url=self._base_url, timeout=timeout
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def base_url(self) -> str:
|
|
150
|
+
return self._base_url
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def api_key(self) -> str | None:
|
|
154
|
+
return self._api_key
|
|
155
|
+
|
|
156
|
+
def set_bearer(self, api_key: str) -> None:
|
|
157
|
+
self._api_key = api_key
|
|
158
|
+
|
|
159
|
+
async def request(
|
|
160
|
+
self,
|
|
161
|
+
method: str,
|
|
162
|
+
path: str,
|
|
163
|
+
*,
|
|
164
|
+
params: dict[str, Any] | None = None,
|
|
165
|
+
json: Any = None,
|
|
166
|
+
require_auth: bool = True,
|
|
167
|
+
) -> Any:
|
|
168
|
+
headers = self._headers(require_auth=require_auth)
|
|
169
|
+
if method.upper() in ("POST", "PUT", "PATCH"):
|
|
170
|
+
headers["Idempotency-Key"] = str(uuid.uuid4())
|
|
171
|
+
last_exc: Exception | None = None
|
|
172
|
+
for attempt in range(self._max_retries + 1):
|
|
173
|
+
try:
|
|
174
|
+
response = await self._http.request(
|
|
175
|
+
method,
|
|
176
|
+
path,
|
|
177
|
+
params=_clean_params(params),
|
|
178
|
+
json=json,
|
|
179
|
+
headers=headers,
|
|
180
|
+
)
|
|
181
|
+
except httpx.TransportError as exc:
|
|
182
|
+
last_exc = exc
|
|
183
|
+
if attempt >= self._max_retries:
|
|
184
|
+
raise ApiError(
|
|
185
|
+
f"transport error after {attempt + 1} attempts: {exc}",
|
|
186
|
+
status_code=0,
|
|
187
|
+
) from exc
|
|
188
|
+
await asyncio.sleep(_backoff_delay(attempt))
|
|
189
|
+
continue
|
|
190
|
+
if response.status_code in _RETRYABLE_STATUSES and attempt < self._max_retries:
|
|
191
|
+
await asyncio.sleep(_backoff_delay(attempt, response))
|
|
192
|
+
continue
|
|
193
|
+
return _parse(response)
|
|
194
|
+
assert last_exc is not None # pragma: no cover
|
|
195
|
+
raise last_exc # pragma: no cover
|
|
196
|
+
|
|
197
|
+
async def aclose(self) -> None:
|
|
198
|
+
if self._owns_client:
|
|
199
|
+
await self._http.aclose()
|
|
200
|
+
|
|
201
|
+
async def __aenter__(self) -> AsyncTransport:
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
205
|
+
await self.aclose()
|
|
206
|
+
|
|
207
|
+
def _headers(self, *, require_auth: bool) -> dict[str, str]:
|
|
208
|
+
headers = {
|
|
209
|
+
"User-Agent": self._user_agent,
|
|
210
|
+
"Accept": "application/json",
|
|
211
|
+
"Accept-Encoding": "gzip",
|
|
212
|
+
}
|
|
213
|
+
if require_auth:
|
|
214
|
+
if not self._api_key:
|
|
215
|
+
raise ConfigurationError(
|
|
216
|
+
"no api_key set; pass api_key= to AsyncClient(...) or call client.login()"
|
|
217
|
+
)
|
|
218
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
219
|
+
return headers
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _clean_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
223
|
+
if params is None:
|
|
224
|
+
return None
|
|
225
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _parse(response: httpx.Response) -> Any:
|
|
229
|
+
if 200 <= response.status_code < 300:
|
|
230
|
+
if not response.content:
|
|
231
|
+
return None
|
|
232
|
+
try:
|
|
233
|
+
return response.json()
|
|
234
|
+
except ValueError as exc:
|
|
235
|
+
raise ApiError(
|
|
236
|
+
f"non-JSON success response: {response.text[:200]}",
|
|
237
|
+
status_code=response.status_code,
|
|
238
|
+
body=response.text,
|
|
239
|
+
) from exc
|
|
240
|
+
body: Any
|
|
241
|
+
try:
|
|
242
|
+
body = response.json()
|
|
243
|
+
except ValueError:
|
|
244
|
+
body = response.text
|
|
245
|
+
code = None
|
|
246
|
+
message = f"HTTP {response.status_code}"
|
|
247
|
+
if isinstance(body, dict):
|
|
248
|
+
code = body.get("code") or body.get("error")
|
|
249
|
+
message = body.get("message") or body.get("error") or message
|
|
250
|
+
raise from_response(response.status_code, code, body, message)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _backoff_delay(attempt: int, response: httpx.Response | None = None) -> float:
|
|
254
|
+
if response is not None:
|
|
255
|
+
retry_after = response.headers.get("Retry-After")
|
|
256
|
+
if retry_after:
|
|
257
|
+
try:
|
|
258
|
+
return max(0.0, float(retry_after))
|
|
259
|
+
except ValueError:
|
|
260
|
+
pass
|
|
261
|
+
return float(min(8.0, 0.5 * (2**attempt)))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _sleep_backoff(attempt: int, response: httpx.Response | None = None) -> None:
|
|
265
|
+
time.sleep(_backoff_delay(attempt, response))
|
voicetel/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.2.10"
|
voicetel/auth.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._http import AsyncTransport, Transport
|
|
6
|
+
from .exceptions import ApiError, AuthenticationError
|
|
7
|
+
|
|
8
|
+
API_KEY_PATH = "/v2.2/account/api-key"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def exchange_api_key(transport: Transport, username: int | str, password: str) -> str:
|
|
12
|
+
"""Exchange username/password for a 32-hex API key and install it on ``transport``.
|
|
13
|
+
|
|
14
|
+
This is the only endpoint that does not require an Authorization header. Subject to the
|
|
15
|
+
6 req/hour/IP rate limit shared by all ``account/*`` endpoints.
|
|
16
|
+
"""
|
|
17
|
+
body = transport.request(
|
|
18
|
+
"POST",
|
|
19
|
+
API_KEY_PATH,
|
|
20
|
+
json={"username": username, "password": password},
|
|
21
|
+
require_auth=False,
|
|
22
|
+
)
|
|
23
|
+
key = _extract_key(body)
|
|
24
|
+
transport.set_bearer(key)
|
|
25
|
+
return key
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def aexchange_api_key(
|
|
29
|
+
transport: AsyncTransport, username: int | str, password: str
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Async counterpart to :func:`exchange_api_key`."""
|
|
32
|
+
body = await transport.request(
|
|
33
|
+
"POST",
|
|
34
|
+
API_KEY_PATH,
|
|
35
|
+
json={"username": username, "password": password},
|
|
36
|
+
require_auth=False,
|
|
37
|
+
)
|
|
38
|
+
key = _extract_key(body)
|
|
39
|
+
transport.set_bearer(key)
|
|
40
|
+
return key
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _extract_key(body: Any) -> str:
|
|
44
|
+
if isinstance(body, dict):
|
|
45
|
+
data = body.get("data")
|
|
46
|
+
if isinstance(data, dict):
|
|
47
|
+
key = data.get("apikey")
|
|
48
|
+
if isinstance(key, str) and key:
|
|
49
|
+
return key
|
|
50
|
+
raise AuthenticationError(
|
|
51
|
+
"api-key response did not contain data.apikey",
|
|
52
|
+
status_code=200,
|
|
53
|
+
body=body,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
__all__ = ["ApiError", "aexchange_api_key", "exchange_api_key"]
|
voicetel/client.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ._http import (
|
|
8
|
+
DEFAULT_BASE_URL,
|
|
9
|
+
DEFAULT_MAX_RETRIES,
|
|
10
|
+
DEFAULT_TIMEOUT,
|
|
11
|
+
AsyncTransport,
|
|
12
|
+
Transport,
|
|
13
|
+
)
|
|
14
|
+
from .auth import aexchange_api_key, exchange_api_key
|
|
15
|
+
from .resources import (
|
|
16
|
+
AccountAsyncResource,
|
|
17
|
+
AccountResource,
|
|
18
|
+
AclAsyncResource,
|
|
19
|
+
AclResource,
|
|
20
|
+
AuthenticationAsyncResource,
|
|
21
|
+
AuthenticationResource,
|
|
22
|
+
E911AsyncResource,
|
|
23
|
+
E911Resource,
|
|
24
|
+
GatewaysAsyncResource,
|
|
25
|
+
GatewaysResource,
|
|
26
|
+
INumberingAsyncResource,
|
|
27
|
+
INumberingResource,
|
|
28
|
+
LookupsAsyncResource,
|
|
29
|
+
LookupsResource,
|
|
30
|
+
MessagingAsyncResource,
|
|
31
|
+
MessagingResource,
|
|
32
|
+
NumbersAsyncResource,
|
|
33
|
+
NumbersResource,
|
|
34
|
+
SupportAsyncResource,
|
|
35
|
+
SupportResource,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Client:
|
|
40
|
+
"""Synchronous client for the VoiceTel REST API.
|
|
41
|
+
|
|
42
|
+
Construct with an existing ``api_key`` or call :meth:`login` to exchange username/password.
|
|
43
|
+
All resource groups are exposed as attributes (``client.numbers``, ``client.account``, ...).
|
|
44
|
+
|
|
45
|
+
Usage as a context manager closes the underlying ``httpx.Client``::
|
|
46
|
+
|
|
47
|
+
with Client(api_key=key) as c:
|
|
48
|
+
account = c.account.get()
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
api_key: str | None = None,
|
|
55
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
56
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
57
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
58
|
+
http_client: httpx.Client | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._transport = Transport(
|
|
61
|
+
api_key=api_key,
|
|
62
|
+
base_url=base_url,
|
|
63
|
+
timeout=timeout,
|
|
64
|
+
max_retries=max_retries,
|
|
65
|
+
http_client=http_client,
|
|
66
|
+
)
|
|
67
|
+
self.account = AccountResource(self._transport)
|
|
68
|
+
self.acl = AclResource(self._transport)
|
|
69
|
+
self.authentication = AuthenticationResource(self._transport)
|
|
70
|
+
self.e911 = E911Resource(self._transport)
|
|
71
|
+
self.gateways = GatewaysResource(self._transport)
|
|
72
|
+
self.inumbering = INumberingResource(self._transport)
|
|
73
|
+
self.lookups = LookupsResource(self._transport)
|
|
74
|
+
self.messaging = MessagingResource(self._transport)
|
|
75
|
+
self.numbers = NumbersResource(self._transport)
|
|
76
|
+
self.support = SupportResource(self._transport)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def api_key(self) -> str | None:
|
|
80
|
+
return self._transport.api_key
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def base_url(self) -> str:
|
|
84
|
+
return self._transport.base_url
|
|
85
|
+
|
|
86
|
+
def login(self, username: int | str, password: str) -> str:
|
|
87
|
+
"""Exchange username/password for an API key and install it. Returns the new key."""
|
|
88
|
+
return exchange_api_key(self._transport, username, password)
|
|
89
|
+
|
|
90
|
+
def close(self) -> None:
|
|
91
|
+
self._transport.close()
|
|
92
|
+
|
|
93
|
+
def __enter__(self) -> Client:
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
def __exit__(self, *_: Any) -> None:
|
|
97
|
+
self.close()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class AsyncClient:
|
|
101
|
+
"""Async counterpart to :class:`Client`. Same surface; methods are awaitable.
|
|
102
|
+
|
|
103
|
+
Use as an async context manager::
|
|
104
|
+
|
|
105
|
+
async with AsyncClient(api_key=key) as c:
|
|
106
|
+
account = await c.account.get()
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
*,
|
|
112
|
+
api_key: str | None = None,
|
|
113
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
114
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
115
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
116
|
+
http_client: httpx.AsyncClient | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
self._transport = AsyncTransport(
|
|
119
|
+
api_key=api_key,
|
|
120
|
+
base_url=base_url,
|
|
121
|
+
timeout=timeout,
|
|
122
|
+
max_retries=max_retries,
|
|
123
|
+
http_client=http_client,
|
|
124
|
+
)
|
|
125
|
+
self.account = AccountAsyncResource(self._transport)
|
|
126
|
+
self.acl = AclAsyncResource(self._transport)
|
|
127
|
+
self.authentication = AuthenticationAsyncResource(self._transport)
|
|
128
|
+
self.e911 = E911AsyncResource(self._transport)
|
|
129
|
+
self.gateways = GatewaysAsyncResource(self._transport)
|
|
130
|
+
self.inumbering = INumberingAsyncResource(self._transport)
|
|
131
|
+
self.lookups = LookupsAsyncResource(self._transport)
|
|
132
|
+
self.messaging = MessagingAsyncResource(self._transport)
|
|
133
|
+
self.numbers = NumbersAsyncResource(self._transport)
|
|
134
|
+
self.support = SupportAsyncResource(self._transport)
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def api_key(self) -> str | None:
|
|
138
|
+
return self._transport.api_key
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def base_url(self) -> str:
|
|
142
|
+
return self._transport.base_url
|
|
143
|
+
|
|
144
|
+
async def login(self, username: int | str, password: str) -> str:
|
|
145
|
+
return await aexchange_api_key(self._transport, username, password)
|
|
146
|
+
|
|
147
|
+
async def aclose(self) -> None:
|
|
148
|
+
await self._transport.aclose()
|
|
149
|
+
|
|
150
|
+
async def __aenter__(self) -> AsyncClient:
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
154
|
+
await self.aclose()
|
voicetel/exceptions.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VoiceTelError(Exception):
|
|
7
|
+
"""Base class for every error raised by this SDK."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigurationError(VoiceTelError):
|
|
11
|
+
"""Raised when the client is constructed with conflicting or missing config."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ApiError(VoiceTelError):
|
|
15
|
+
"""Raised when the API returns a non-2xx response.
|
|
16
|
+
|
|
17
|
+
Subclasses cover specific status families; catch :class:`ApiError` to handle them all.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
status_code: int
|
|
21
|
+
code: str | None
|
|
22
|
+
body: Any
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
message: str,
|
|
27
|
+
*,
|
|
28
|
+
status_code: int,
|
|
29
|
+
code: str | None = None,
|
|
30
|
+
body: Any = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
self.code = code
|
|
35
|
+
self.body = body
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
|
+
return f"{type(self).__name__}(status_code={self.status_code}, code={self.code!r})"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BadRequestError(ApiError):
|
|
42
|
+
"""HTTP 400 — the request was malformed or failed server-side validation."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuthenticationError(ApiError):
|
|
46
|
+
"""HTTP 401 — bearer token is missing, expired, or invalid."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PermissionDeniedError(ApiError):
|
|
50
|
+
"""HTTP 403 — authenticated, but not allowed to perform this action."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class NotFoundError(ApiError):
|
|
54
|
+
"""HTTP 404 — the resource does not exist."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ConflictError(ApiError):
|
|
58
|
+
"""HTTP 409 — request conflicts with current resource state."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RateLimitError(ApiError):
|
|
62
|
+
"""HTTP 429 — too many requests. The `account/*` endpoints allow only 6 req/hour/IP."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ServerError(ApiError):
|
|
66
|
+
"""HTTP 5xx — the server hit an error processing the request."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def from_response(status_code: int, code: str | None, body: Any, message: str) -> ApiError:
|
|
70
|
+
"""Map an HTTP status to the most specific :class:`ApiError` subclass."""
|
|
71
|
+
cls: type[ApiError]
|
|
72
|
+
if status_code == 400:
|
|
73
|
+
cls = BadRequestError
|
|
74
|
+
elif status_code == 401:
|
|
75
|
+
cls = AuthenticationError
|
|
76
|
+
elif status_code == 403:
|
|
77
|
+
cls = PermissionDeniedError
|
|
78
|
+
elif status_code == 404:
|
|
79
|
+
cls = NotFoundError
|
|
80
|
+
elif status_code == 409:
|
|
81
|
+
cls = ConflictError
|
|
82
|
+
elif status_code == 429:
|
|
83
|
+
cls = RateLimitError
|
|
84
|
+
elif 500 <= status_code < 600:
|
|
85
|
+
cls = ServerError
|
|
86
|
+
else:
|
|
87
|
+
cls = ApiError
|
|
88
|
+
return cls(message, status_code=status_code, code=code, body=body)
|