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