yama-client 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yama/__init__.py +82 -0
- yama/_client.py +404 -0
- yama/_errors.py +123 -0
- yama/_pagination.py +43 -0
- yama/_resources.py +287 -0
- yama/models.py +278 -0
- yama/py.typed +0 -0
- yama/testing.py +353 -0
- yama_client-0.1.0.dist-info/METADATA +101 -0
- yama_client-0.1.0.dist-info/RECORD +12 -0
- yama_client-0.1.0.dist-info/WHEEL +4 -0
- yama_client-0.1.0.dist-info/licenses/LICENSE +21 -0
yama/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The official Python client for the Yama API."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
|
|
6
|
+
from yama._client import DEFAULT_BASE_URL, Yama
|
|
7
|
+
from yama._errors import (
|
|
8
|
+
APIConnectionError,
|
|
9
|
+
APIStatusError,
|
|
10
|
+
APITimeoutError,
|
|
11
|
+
AuthenticationError,
|
|
12
|
+
BadRequestError,
|
|
13
|
+
ConflictError,
|
|
14
|
+
GoneError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
PermissionDeniedError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
ServerError,
|
|
19
|
+
UnprocessableEntityError,
|
|
20
|
+
YamaError,
|
|
21
|
+
)
|
|
22
|
+
from yama.models import (
|
|
23
|
+
AccessCode,
|
|
24
|
+
AccessCodeCreate,
|
|
25
|
+
AccessCodeUpdate,
|
|
26
|
+
AccessLevel,
|
|
27
|
+
ApiKeyDetails,
|
|
28
|
+
ApiKeyPermissions,
|
|
29
|
+
ApiKeyUnitAccess,
|
|
30
|
+
Building,
|
|
31
|
+
CallLogEntry,
|
|
32
|
+
CallLogStep,
|
|
33
|
+
CallResult,
|
|
34
|
+
CallStepKind,
|
|
35
|
+
CodeAttemptResult,
|
|
36
|
+
Contact,
|
|
37
|
+
DialStatus,
|
|
38
|
+
DoorDigit,
|
|
39
|
+
Unit,
|
|
40
|
+
UnitAccessType,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__version__ = version("yama-client")
|
|
44
|
+
|
|
45
|
+
logging.getLogger("yama").addHandler(logging.NullHandler())
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"DEFAULT_BASE_URL",
|
|
49
|
+
"APIConnectionError",
|
|
50
|
+
"APIStatusError",
|
|
51
|
+
"APITimeoutError",
|
|
52
|
+
"AccessCode",
|
|
53
|
+
"AccessCodeCreate",
|
|
54
|
+
"AccessCodeUpdate",
|
|
55
|
+
"AccessLevel",
|
|
56
|
+
"ApiKeyDetails",
|
|
57
|
+
"ApiKeyPermissions",
|
|
58
|
+
"ApiKeyUnitAccess",
|
|
59
|
+
"AuthenticationError",
|
|
60
|
+
"BadRequestError",
|
|
61
|
+
"Building",
|
|
62
|
+
"CallLogEntry",
|
|
63
|
+
"CallLogStep",
|
|
64
|
+
"CallResult",
|
|
65
|
+
"CallStepKind",
|
|
66
|
+
"CodeAttemptResult",
|
|
67
|
+
"ConflictError",
|
|
68
|
+
"Contact",
|
|
69
|
+
"DialStatus",
|
|
70
|
+
"DoorDigit",
|
|
71
|
+
"GoneError",
|
|
72
|
+
"NotFoundError",
|
|
73
|
+
"PermissionDeniedError",
|
|
74
|
+
"RateLimitError",
|
|
75
|
+
"ServerError",
|
|
76
|
+
"Unit",
|
|
77
|
+
"UnitAccessType",
|
|
78
|
+
"UnprocessableEntityError",
|
|
79
|
+
"Yama",
|
|
80
|
+
"YamaError",
|
|
81
|
+
"__version__",
|
|
82
|
+
]
|
yama/_client.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""The client: configuration, sending, retries and error mapping.
|
|
2
|
+
|
|
3
|
+
The rules (what to send, whether and how long to wait, what a response means) are plain
|
|
4
|
+
functions free of I/O; `Yama` only sends with httpx and sleeps.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import random
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
16
|
+
from importlib.metadata import version
|
|
17
|
+
from types import TracebackType
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from yama._errors import (
|
|
23
|
+
APIConnectionError,
|
|
24
|
+
APIStatusError,
|
|
25
|
+
APITimeoutError,
|
|
26
|
+
AuthenticationError,
|
|
27
|
+
BadRequestError,
|
|
28
|
+
ConflictError,
|
|
29
|
+
GoneError,
|
|
30
|
+
NotFoundError,
|
|
31
|
+
PermissionDeniedError,
|
|
32
|
+
RateLimitError,
|
|
33
|
+
ServerError,
|
|
34
|
+
UnprocessableEntityError,
|
|
35
|
+
YamaError,
|
|
36
|
+
)
|
|
37
|
+
from yama._pagination import T, paginate
|
|
38
|
+
from yama._resources import ApiKeyResource, Buildings, Calls, Codes, Contacts, Units
|
|
39
|
+
|
|
40
|
+
DEFAULT_BASE_URL = "https://app.yamasystems.com"
|
|
41
|
+
"""Where the client sends requests unless `base_url` or `YAMA_API_BASE_URL` says otherwise."""
|
|
42
|
+
|
|
43
|
+
_PYTHON = f"{sys.version_info[0]}.{sys.version_info[1]}"
|
|
44
|
+
_USER_AGENT = f"yama-python/{version('yama-client')} python/{_PYTHON}"
|
|
45
|
+
_MAX_RETRY_AFTER = 60
|
|
46
|
+
_ERRORS: dict[int, type[APIStatusError]] = {
|
|
47
|
+
400: BadRequestError,
|
|
48
|
+
401: AuthenticationError,
|
|
49
|
+
403: PermissionDeniedError,
|
|
50
|
+
404: NotFoundError,
|
|
51
|
+
409: ConflictError,
|
|
52
|
+
410: GoneError,
|
|
53
|
+
422: UnprocessableEntityError,
|
|
54
|
+
429: RateLimitError,
|
|
55
|
+
}
|
|
56
|
+
# Retried: the connection failed or broke, or an attempt timed out. Other httpx errors (an
|
|
57
|
+
# unsupported scheme, a bad header, a proxy refusal, too many redirects) would fail again.
|
|
58
|
+
_TRANSPORT_FAILURES = (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError)
|
|
59
|
+
# Of those, the ones where the request never left the client, so the API can't have run it.
|
|
60
|
+
_NOT_SENT = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
|
|
61
|
+
NOT_GIVEN: Any = object() # an argument left out, where None means something else
|
|
62
|
+
_logger = logging.getLogger("yama")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _backoff(attempt: int) -> float:
|
|
66
|
+
"""Full jitter: a random wait up to 0.5 s, doubling with each attempt, capped at 8 s."""
|
|
67
|
+
return random.uniform(0, min(8.0, 0.5 * 2 ** (attempt - 1)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _retry_after(response: httpx.Response) -> int | None:
|
|
71
|
+
value = response.headers.get("Retry-After", "")
|
|
72
|
+
return int(value) if value.isdigit() else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _retry_wait(
|
|
76
|
+
attempt: int,
|
|
77
|
+
max_retries: int,
|
|
78
|
+
request: httpx.Request,
|
|
79
|
+
result: httpx.Response | httpx.HTTPError,
|
|
80
|
+
) -> float | None:
|
|
81
|
+
"""Seconds to wait before retrying `request` after `attempt` (from 1) failed with
|
|
82
|
+
`result`, a response or an httpx error; None means don't retry.
|
|
83
|
+
|
|
84
|
+
A 429, or a failure to connect, means the API never ran the request, so any method is
|
|
85
|
+
retried. A 5xx or any other network failure may come after the API ran it, so a POST is
|
|
86
|
+
retried only with an `Idempotency-Key` (whose 409 means "still running"); every other
|
|
87
|
+
method is safe to repeat.
|
|
88
|
+
"""
|
|
89
|
+
if attempt > max_retries:
|
|
90
|
+
return None
|
|
91
|
+
keyed = "Idempotency-Key" in request.headers
|
|
92
|
+
replayable = keyed or request.method != "POST"
|
|
93
|
+
if isinstance(result, httpx.HTTPError):
|
|
94
|
+
if isinstance(result, _NOT_SENT) or (
|
|
95
|
+
replayable and isinstance(result, _TRANSPORT_FAILURES)
|
|
96
|
+
):
|
|
97
|
+
return _backoff(attempt)
|
|
98
|
+
return None
|
|
99
|
+
status = result.status_code
|
|
100
|
+
if status == 429 or (status == 409 and keyed):
|
|
101
|
+
retry_after = _retry_after(result)
|
|
102
|
+
if retry_after is not None:
|
|
103
|
+
return retry_after if retry_after <= _MAX_RETRY_AFTER else None
|
|
104
|
+
return _backoff(attempt)
|
|
105
|
+
return _backoff(attempt) if status >= 500 and replayable else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _check_timeout(timeout: object) -> httpx.Timeout:
|
|
109
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float, httpx.Timeout)):
|
|
110
|
+
raise TypeError(
|
|
111
|
+
"timeout must be a number of seconds or an httpx.Timeout; "
|
|
112
|
+
"for no timeout at all, pass httpx.Timeout(None)."
|
|
113
|
+
)
|
|
114
|
+
return timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _check_max_retries(max_retries: object) -> int:
|
|
118
|
+
if isinstance(max_retries, bool) or not isinstance(max_retries, int):
|
|
119
|
+
raise TypeError("max_retries must be an int.")
|
|
120
|
+
if max_retries < 0:
|
|
121
|
+
raise ValueError("max_retries must be 0 or more.")
|
|
122
|
+
return max_retries
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _status_error(request: httpx.Request, response: httpx.Response) -> APIStatusError:
|
|
126
|
+
"""The error for a non-2xx response."""
|
|
127
|
+
body: object
|
|
128
|
+
try:
|
|
129
|
+
body = response.json()
|
|
130
|
+
except ValueError:
|
|
131
|
+
body = response.text
|
|
132
|
+
status = response.status_code
|
|
133
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
134
|
+
if not isinstance(detail, str):
|
|
135
|
+
if isinstance(body, dict) and body:
|
|
136
|
+
detail = "; ".join(
|
|
137
|
+
f"{key}: {' '.join(map(str, value)) if isinstance(value, list) else value}"
|
|
138
|
+
for key, value in body.items()
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
detail = response.reason_phrase
|
|
142
|
+
args = (
|
|
143
|
+
status,
|
|
144
|
+
detail,
|
|
145
|
+
body,
|
|
146
|
+
response.headers.get("API-Version"),
|
|
147
|
+
request.method,
|
|
148
|
+
request.url.path,
|
|
149
|
+
response.headers.get("Request-Id"),
|
|
150
|
+
)
|
|
151
|
+
error_class = _ERRORS.get(status, ServerError if status >= 500 else APIStatusError)
|
|
152
|
+
if error_class is RateLimitError:
|
|
153
|
+
return RateLimitError(*args, retry_after=_retry_after(response))
|
|
154
|
+
return error_class(*args)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _success(request: httpx.Request, response: httpx.Response) -> Any:
|
|
158
|
+
"""The parsed JSON of a 2xx response, or None when it has no body."""
|
|
159
|
+
if not response.content:
|
|
160
|
+
return None
|
|
161
|
+
try:
|
|
162
|
+
return response.json()
|
|
163
|
+
except ValueError:
|
|
164
|
+
raise YamaError(
|
|
165
|
+
f"Invalid JSON in the response to {request.method} {request.url.path}."
|
|
166
|
+
) from None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _connection_error(request: httpx.Request, exc: httpx.HTTPError) -> APIConnectionError:
|
|
170
|
+
"""The error for a failed attempt. It doesn't chain `exc`, which holds the request and so
|
|
171
|
+
the key."""
|
|
172
|
+
where = f"{request.method} {request.url.path} ({type(exc).__name__})"
|
|
173
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
174
|
+
return APITimeoutError(f"Request timed out: {where}")
|
|
175
|
+
return APIConnectionError(f"Connection failed: {where}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _origin(url: str) -> tuple[str, str, int | None] | None:
|
|
179
|
+
"""A URL's scheme, host and port, or None when it isn't a valid URL."""
|
|
180
|
+
try:
|
|
181
|
+
parsed = httpx.URL(url)
|
|
182
|
+
except httpx.InvalidURL:
|
|
183
|
+
return None
|
|
184
|
+
return parsed.scheme, parsed.host, parsed.port
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _log(level: int, message: str, request: httpx.Request, **facts: object) -> None:
|
|
188
|
+
"""Log `message`, formatted with the request's method and path (never its query or
|
|
189
|
+
headers) and `facts`, each also as a `yama_`-prefixed extra field."""
|
|
190
|
+
if _logger.isEnabledFor(level):
|
|
191
|
+
facts = {"method": request.method, "path": request.url.path, **facts}
|
|
192
|
+
_logger.log(level, message, facts, extra={f"yama_{k}": v for k, v in facts.items()})
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class Yama:
|
|
196
|
+
"""A client for the Yama API.
|
|
197
|
+
|
|
198
|
+
Use it as a context manager, or call `close()` when done. One client can be shared
|
|
199
|
+
between threads.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
api_key: The API key. Defaults to `YAMA_API_KEY`; passing None doesn't fall back to it.
|
|
203
|
+
base_url: Where to send requests; else `YAMA_API_BASE_URL`, else `DEFAULT_BASE_URL`.
|
|
204
|
+
timeout: Seconds allowed for each phase of an attempt (connect, write, read, pool),
|
|
205
|
+
or an `httpx.Timeout` to set them one by one.
|
|
206
|
+
max_retries: How many times to retry a request after a 429, a 5xx or a network error.
|
|
207
|
+
http_client: Your own `httpx.Client` to send through. The client applies its own
|
|
208
|
+
URL, timeout and headers to each request, and leaves closing it to you.
|
|
209
|
+
An `httpx.AsyncClient` isn't supported.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
YamaError: No API key was given.
|
|
213
|
+
TypeError: `api_key` isn't a string, `timeout` isn't a number of seconds or an
|
|
214
|
+
`httpx.Timeout`, `max_retries` isn't an int, or `http_client` isn't an
|
|
215
|
+
`httpx.Client`.
|
|
216
|
+
ValueError: The API key isn't printable ASCII without spaces, once stripped,
|
|
217
|
+
`base_url` doesn't start with `https://` or `http://`, or `max_retries` is
|
|
218
|
+
negative.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
api_key: ApiKeyResource
|
|
222
|
+
"""The API key the client uses: what it may do."""
|
|
223
|
+
buildings: Buildings
|
|
224
|
+
"""Your buildings."""
|
|
225
|
+
units: Units
|
|
226
|
+
"""Your units."""
|
|
227
|
+
contacts: Contacts
|
|
228
|
+
"""The contacts on your units."""
|
|
229
|
+
codes: Codes
|
|
230
|
+
"""The access codes on your units."""
|
|
231
|
+
calls: Calls
|
|
232
|
+
"""The call log."""
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
api_key: str | None = NOT_GIVEN,
|
|
237
|
+
*,
|
|
238
|
+
base_url: str | None = None,
|
|
239
|
+
timeout: float | httpx.Timeout = 30.0,
|
|
240
|
+
max_retries: int = 2,
|
|
241
|
+
http_client: httpx.Client | None = None,
|
|
242
|
+
) -> None:
|
|
243
|
+
if api_key is NOT_GIVEN:
|
|
244
|
+
api_key = os.environ.get("YAMA_API_KEY")
|
|
245
|
+
elif api_key is not None and not isinstance(api_key, str):
|
|
246
|
+
raise TypeError("api_key must be a string.")
|
|
247
|
+
api_key = (api_key or "").strip()
|
|
248
|
+
if not api_key:
|
|
249
|
+
raise YamaError("No API key: pass api_key, or set YAMA_API_KEY.")
|
|
250
|
+
if not all("!" <= char <= "~" for char in api_key):
|
|
251
|
+
raise ValueError("api_key must be printable ASCII without spaces.")
|
|
252
|
+
base_url = base_url or os.environ.get("YAMA_API_BASE_URL") or DEFAULT_BASE_URL
|
|
253
|
+
if not base_url.startswith(("https://", "http://")):
|
|
254
|
+
raise ValueError("base_url must start with https:// or http://.")
|
|
255
|
+
if http_client is not None and not isinstance(http_client, httpx.Client):
|
|
256
|
+
raise TypeError("http_client must be an httpx.Client: the client isn't async yet.")
|
|
257
|
+
|
|
258
|
+
self._key = api_key
|
|
259
|
+
self._base_url = base_url.rstrip("/")
|
|
260
|
+
self._timeout = _check_timeout(timeout)
|
|
261
|
+
self._max_retries = _check_max_retries(max_retries)
|
|
262
|
+
self._owns_http = http_client is None
|
|
263
|
+
self._closed = False
|
|
264
|
+
self._http = http_client or httpx.Client()
|
|
265
|
+
self._sleep: Callable[[float], None] = time.sleep
|
|
266
|
+
self._attach()
|
|
267
|
+
|
|
268
|
+
def _attach(self) -> None:
|
|
269
|
+
self.api_key = ApiKeyResource(self)
|
|
270
|
+
self.buildings = Buildings(self)
|
|
271
|
+
self.units = Units(self)
|
|
272
|
+
self.contacts = Contacts(self)
|
|
273
|
+
self.codes = Codes(self)
|
|
274
|
+
self.calls = Calls(self)
|
|
275
|
+
|
|
276
|
+
def with_options(
|
|
277
|
+
self,
|
|
278
|
+
*,
|
|
279
|
+
timeout: float | httpx.Timeout = NOT_GIVEN,
|
|
280
|
+
max_retries: int = NOT_GIVEN,
|
|
281
|
+
) -> Yama:
|
|
282
|
+
"""A copy of the client with a different `timeout` or `max_retries`, sharing its
|
|
283
|
+
connections, key and base URL, such as a fail-fast client for a web view.
|
|
284
|
+
|
|
285
|
+
Closing the copy leaves the connections open; closing this client closes them for
|
|
286
|
+
both.
|
|
287
|
+
"""
|
|
288
|
+
clone = copy.copy(self)
|
|
289
|
+
if timeout is not NOT_GIVEN:
|
|
290
|
+
clone._timeout = _check_timeout(timeout)
|
|
291
|
+
if max_retries is not NOT_GIVEN:
|
|
292
|
+
clone._max_retries = _check_max_retries(max_retries)
|
|
293
|
+
clone._owns_http = False
|
|
294
|
+
clone._attach()
|
|
295
|
+
return clone
|
|
296
|
+
|
|
297
|
+
def __repr__(self) -> str:
|
|
298
|
+
return f"Yama(base_url={self._base_url!r})"
|
|
299
|
+
|
|
300
|
+
def __enter__(self) -> Yama:
|
|
301
|
+
return self
|
|
302
|
+
|
|
303
|
+
def __exit__(
|
|
304
|
+
self,
|
|
305
|
+
exc_type: type[BaseException] | None,
|
|
306
|
+
exc: BaseException | None,
|
|
307
|
+
traceback: TracebackType | None,
|
|
308
|
+
) -> None:
|
|
309
|
+
self.close()
|
|
310
|
+
|
|
311
|
+
def close(self) -> None:
|
|
312
|
+
"""Close the client's connections, unless they belong to the `http_client` you passed.
|
|
313
|
+
Using the client afterwards raises `ValueError`."""
|
|
314
|
+
self._closed = True
|
|
315
|
+
if self._owns_http:
|
|
316
|
+
self._http.close()
|
|
317
|
+
|
|
318
|
+
def _request(
|
|
319
|
+
self,
|
|
320
|
+
method: str,
|
|
321
|
+
path: str,
|
|
322
|
+
*,
|
|
323
|
+
params: Mapping[str, Any] | None = None,
|
|
324
|
+
json: object = None,
|
|
325
|
+
headers: Mapping[str, str] | None = None,
|
|
326
|
+
) -> Any:
|
|
327
|
+
"""Send a request, retrying as the API allows, and return its parsed JSON.
|
|
328
|
+
|
|
329
|
+
`path` is relative to `/api/v2/`, or a full URL such as a page's `next`, which must be
|
|
330
|
+
on the base URL so the key never goes elsewhere.
|
|
331
|
+
"""
|
|
332
|
+
if self._closed or self._http.is_closed:
|
|
333
|
+
raise ValueError("The client is closed.")
|
|
334
|
+
url = path if "://" in path else f"{self._base_url}/api/v2/{path}"
|
|
335
|
+
origin = _origin(url)
|
|
336
|
+
if origin is None or origin != _origin(self._base_url):
|
|
337
|
+
raise YamaError("Refusing to follow a URL that isn't on the configured base URL.")
|
|
338
|
+
request = self._http.build_request(
|
|
339
|
+
method,
|
|
340
|
+
url,
|
|
341
|
+
params={k: v for k, v in params.items() if v is not None} if params else None,
|
|
342
|
+
json=json,
|
|
343
|
+
headers={
|
|
344
|
+
**(headers or {}),
|
|
345
|
+
"Authorization": f"Bearer {self._key}",
|
|
346
|
+
"User-Agent": _USER_AGENT,
|
|
347
|
+
},
|
|
348
|
+
timeout=self._timeout,
|
|
349
|
+
)
|
|
350
|
+
attempt = 0
|
|
351
|
+
may_have_run = False # whether an earlier attempt may have reached the API (RTY-8)
|
|
352
|
+
while True:
|
|
353
|
+
attempt += 1
|
|
354
|
+
started = time.monotonic()
|
|
355
|
+
response: httpx.Response | None = None
|
|
356
|
+
try:
|
|
357
|
+
response = self._http.send(request)
|
|
358
|
+
except httpx.HTTPError as exc:
|
|
359
|
+
result: httpx.Response | httpx.HTTPError = exc
|
|
360
|
+
error: YamaError = _connection_error(request, exc)
|
|
361
|
+
outcome = type(exc).__name__
|
|
362
|
+
else:
|
|
363
|
+
result = response
|
|
364
|
+
outcome = str(response.status_code)
|
|
365
|
+
_log(
|
|
366
|
+
logging.DEBUG,
|
|
367
|
+
"%(method)s %(path)s %(outcome)s in %(elapsed).3fs (attempt %(attempt)d)",
|
|
368
|
+
request,
|
|
369
|
+
outcome=outcome,
|
|
370
|
+
status=None if response is None else response.status_code,
|
|
371
|
+
elapsed=time.monotonic() - started,
|
|
372
|
+
attempt=attempt,
|
|
373
|
+
request_id=None if response is None else response.headers.get("Request-Id"),
|
|
374
|
+
)
|
|
375
|
+
if response is not None:
|
|
376
|
+
if response.is_success:
|
|
377
|
+
return _success(request, response)
|
|
378
|
+
if may_have_run and request.method == "DELETE" and response.status_code == 404:
|
|
379
|
+
return None # an earlier attempt deleted it (RTY-8)
|
|
380
|
+
error = _status_error(request, response)
|
|
381
|
+
wait = _retry_wait(attempt, self._max_retries, request, result)
|
|
382
|
+
if wait is None:
|
|
383
|
+
raise error
|
|
384
|
+
# A 429 or a failure to connect means the API never ran it; anything else retried may.
|
|
385
|
+
may_have_run |= not (
|
|
386
|
+
isinstance(result, _NOT_SENT)
|
|
387
|
+
or (response is not None and response.status_code == 429)
|
|
388
|
+
)
|
|
389
|
+
_log(
|
|
390
|
+
logging.INFO,
|
|
391
|
+
"Retrying %(method)s %(path)s in %(wait).2fs (attempt %(attempt)d) "
|
|
392
|
+
"after %(reason)s",
|
|
393
|
+
request,
|
|
394
|
+
reason=outcome,
|
|
395
|
+
wait=wait,
|
|
396
|
+
attempt=attempt + 1,
|
|
397
|
+
)
|
|
398
|
+
self._sleep(wait)
|
|
399
|
+
|
|
400
|
+
def _list(
|
|
401
|
+
self, path: str, model: type[T], params: Mapping[str, object] | None = None
|
|
402
|
+
) -> Iterator[T]:
|
|
403
|
+
"""A new iterator over every item of a list; it sends nothing until iterated."""
|
|
404
|
+
return paginate(self._request, path, model, params)
|
yama/_errors.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""The exceptions the client raises. They hold plain fields only, so they pickle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class YamaError(Exception):
|
|
7
|
+
"""Base class for every error the client raises. Raised itself for misconfiguration, or a
|
|
8
|
+
response the client can't read."""
|
|
9
|
+
|
|
10
|
+
idempotency_key: str | None = None
|
|
11
|
+
"""The idempotency key a failed `codes.create` sent, so you can send it again with it;
|
|
12
|
+
None for any other call."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class APIConnectionError(YamaError):
|
|
16
|
+
"""The request failed at the network level, after retries. It may never have been sent."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class APITimeoutError(APIConnectionError):
|
|
20
|
+
"""The last attempt timed out, after retries. The API may still have processed it."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class APIStatusError(YamaError):
|
|
24
|
+
"""The base class for HTTP errors; raised itself for a status without its own class.
|
|
25
|
+
Quote its `request_id` to Yama support when asking about a failed call."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
status_code: int,
|
|
30
|
+
detail: str,
|
|
31
|
+
body: object,
|
|
32
|
+
api_version: str | None,
|
|
33
|
+
method: str,
|
|
34
|
+
path: str,
|
|
35
|
+
request_id: str | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(status_code, detail, body, api_version, method, path, request_id)
|
|
38
|
+
self.status_code = status_code
|
|
39
|
+
"""The HTTP status code."""
|
|
40
|
+
self.detail = detail
|
|
41
|
+
"""The body's `detail`, or else its field errors joined, or else the HTTP reason."""
|
|
42
|
+
self.body = body
|
|
43
|
+
"""The parsed JSON body, or its text when it isn't JSON."""
|
|
44
|
+
self.api_version = api_version
|
|
45
|
+
"""The response's `API-Version` header."""
|
|
46
|
+
self.method = method
|
|
47
|
+
"""The request's HTTP method."""
|
|
48
|
+
self.path = path
|
|
49
|
+
"""The request's URL path, without its query string."""
|
|
50
|
+
self.request_id = request_id
|
|
51
|
+
"""The response's `Request-Id` header, which Yama support asks for to find the request;
|
|
52
|
+
None when it has none."""
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
where = f"{self.method} {self.path}"
|
|
56
|
+
if self.request_id:
|
|
57
|
+
where += f", Request-Id {self.request_id}"
|
|
58
|
+
return f"{self.status_code} {self.detail} ({where})"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BadRequestError(APIStatusError):
|
|
62
|
+
"""400: a field or query parameter is invalid, blank or unknown."""
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def errors(self) -> dict[str, list[str]]:
|
|
66
|
+
"""Each invalid field or query parameter (or a key such as `non_field_errors`),
|
|
67
|
+
mapped to its messages."""
|
|
68
|
+
if not isinstance(self.body, dict):
|
|
69
|
+
return {}
|
|
70
|
+
return {k: v for k, v in self.body.items() if isinstance(v, list)}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AuthenticationError(APIStatusError):
|
|
74
|
+
"""401: the API key is malformed, unknown, deleted or expired."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PermissionDeniedError(APIStatusError):
|
|
78
|
+
"""403: the API key lacks the permission this operation needs."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class NotFoundError(APIStatusError):
|
|
82
|
+
"""404: no such object within the API key's reach."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ConflictError(APIStatusError):
|
|
86
|
+
"""409: a request with the same idempotency key is still being processed, after
|
|
87
|
+
retries."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class GoneError(APIStatusError):
|
|
91
|
+
"""410: a create repeated with its idempotency key did happen, but the code it created has
|
|
92
|
+
since been deleted or is out of the API key's reach. Don't create it again with that
|
|
93
|
+
idempotency key."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class UnprocessableEntityError(APIStatusError):
|
|
97
|
+
"""422: the idempotency key was already used with a different request. Each create needs
|
|
98
|
+
its own idempotency key."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RateLimitError(APIStatusError):
|
|
102
|
+
"""429: too many requests, after retries, or at once when the wait is over 60 seconds."""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
status_code: int,
|
|
107
|
+
detail: str,
|
|
108
|
+
body: object,
|
|
109
|
+
api_version: str | None,
|
|
110
|
+
method: str,
|
|
111
|
+
path: str,
|
|
112
|
+
request_id: str | None = None,
|
|
113
|
+
retry_after: int | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
super().__init__(status_code, detail, body, api_version, method, path, request_id)
|
|
116
|
+
self.args = (*self.args, retry_after)
|
|
117
|
+
self.retry_after = retry_after
|
|
118
|
+
"""Seconds the API asked the client to wait, from `Retry-After`; None when it didn't
|
|
119
|
+
say."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ServerError(APIStatusError):
|
|
123
|
+
"""5xx: the API failed."""
|
yama/_pagination.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Paging through lists, and parsing responses into models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ValidationError
|
|
9
|
+
|
|
10
|
+
from yama._errors import YamaError
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T", bound=BaseModel)
|
|
13
|
+
PAGE_SIZE = 500 # the API's maximum, for the fewest requests under the rate limit
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _Page(BaseModel):
|
|
17
|
+
next: str | None = None
|
|
18
|
+
results: list[Any]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse(model: type[T], data: object) -> T:
|
|
22
|
+
"""Validate `data` as `model`, raising `YamaError` if the response doesn't fit."""
|
|
23
|
+
try:
|
|
24
|
+
return model.model_validate(data)
|
|
25
|
+
except ValidationError as exc:
|
|
26
|
+
raise YamaError(f"Unexpected response: not a valid {model.__name__}.") from exc
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def paginate(
|
|
30
|
+
request: Callable[..., Any],
|
|
31
|
+
path: str,
|
|
32
|
+
model: type[T],
|
|
33
|
+
params: Mapping[str, object] | None = None,
|
|
34
|
+
) -> Iterator[T]:
|
|
35
|
+
"""Yield every item of a list, requesting each page as iteration reaches it and following
|
|
36
|
+
`next` as given until it is null."""
|
|
37
|
+
url: str | None = path
|
|
38
|
+
query: dict[str, object] | None = {**(params or {}), "limit": PAGE_SIZE}
|
|
39
|
+
while url:
|
|
40
|
+
page = parse(_Page, request("GET", url, params=query))
|
|
41
|
+
for item in page.results:
|
|
42
|
+
yield parse(model, item)
|
|
43
|
+
url, query = page.next, None
|