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 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)