status302 1.5.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.
status302/__init__.py ADDED
@@ -0,0 +1,69 @@
1
+ """Official Python SDK for the Status302 API."""
2
+
3
+ from . import types
4
+ from ._base import APIResponse, CachedResponse, ETagCache, MemoryETagCache, NotModified
5
+ from ._client import AsyncStatus302, Status302
6
+ from ._errors import (
7
+ APIConnectionError,
8
+ APIError,
9
+ AuthenticationError,
10
+ BadRequestError,
11
+ ConflictError,
12
+ NotFoundError,
13
+ PermissionDeniedError,
14
+ PlanRequiredError,
15
+ RateLimitError,
16
+ ServerError,
17
+ Status302Error,
18
+ )
19
+ from ._ratelimit import RateLimitInfo, RateLimitPolicy, parse_rate_limit, parse_retry_after
20
+ from ._version import __version__
21
+ from .stream import (
22
+ DataMessage,
23
+ ErrorMessage,
24
+ ResyncMessage,
25
+ StreamClosedError,
26
+ Subscription,
27
+ SubscriptionError,
28
+ Status302Stream,
29
+ stream_url,
30
+ )
31
+ from .webhooks import WebhookVerificationError, sign_webhook, verify_webhook
32
+
33
+ __all__ = [
34
+ "APIConnectionError",
35
+ "APIError",
36
+ "APIResponse",
37
+ "AsyncStatus302",
38
+ "AuthenticationError",
39
+ "BadRequestError",
40
+ "CachedResponse",
41
+ "ConflictError",
42
+ "DataMessage",
43
+ "ETagCache",
44
+ "ErrorMessage",
45
+ "MemoryETagCache",
46
+ "NotFoundError",
47
+ "NotModified",
48
+ "PermissionDeniedError",
49
+ "PlanRequiredError",
50
+ "RateLimitError",
51
+ "RateLimitInfo",
52
+ "RateLimitPolicy",
53
+ "ResyncMessage",
54
+ "ServerError",
55
+ "StreamClosedError",
56
+ "Subscription",
57
+ "SubscriptionError",
58
+ "Status302",
59
+ "Status302Error",
60
+ "Status302Stream",
61
+ "WebhookVerificationError",
62
+ "__version__",
63
+ "parse_rate_limit",
64
+ "parse_retry_after",
65
+ "sign_webhook",
66
+ "stream_url",
67
+ "types",
68
+ "verify_webhook",
69
+ ]
status302/_base.py ADDED
@@ -0,0 +1,444 @@
1
+ """HTTP transport shared by the sync and async clients: auth, retries, errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import random
8
+ import threading
9
+ import time
10
+ from collections import OrderedDict
11
+ from collections.abc import Callable, Mapping, Sequence
12
+ from dataclasses import dataclass
13
+ from typing import Any, Generic, Protocol, TypeVar
14
+ from urllib.parse import quote
15
+
16
+ import httpx
17
+
18
+ from ._errors import APIConnectionError, error_for
19
+ from ._ratelimit import RateLimitInfo, parse_rate_limit, parse_retry_after
20
+ from ._version import __version__
21
+
22
+ DEFAULT_BASE_URL = "https://api.status302.com"
23
+ IDEMPOTENT = frozenset({"GET", "HEAD", "PUT", "DELETE", "OPTIONS"})
24
+ BASE_DELAY = 0.5
25
+ MAX_DELAY = 30.0
26
+
27
+ T_co = TypeVar("T_co", covariant=True)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class APIResponse(Generic[T_co]):
32
+ """A decoded response with its status, headers and rate limit state."""
33
+
34
+ data: T_co
35
+ status: int
36
+ """HTTP status; 304 when the body was revalidated from the client's ``etag_cache`` or is ``NotModified``."""
37
+ headers: httpx.Headers
38
+ rate_limit: RateLimitInfo | None
39
+
40
+ @property
41
+ def etag(self) -> str | None:
42
+ """The ``ETag`` response header: pass it as ``if_none_match`` to revalidate."""
43
+ etag: str | None = self.headers.get("etag")
44
+ return etag
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class NotModified:
49
+ """The answer to a conditional request (``if_none_match``) while the copy with that ETag is still current (HTTP 304)."""
50
+
51
+ etag: str | None
52
+ """The current ETag, equal to (one of) the ETags sent."""
53
+ headers: httpx.Headers
54
+ rate_limit: RateLimitInfo | None
55
+
56
+ @property
57
+ def status(self) -> int:
58
+ return 304
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class CachedResponse:
63
+ """A response body stored by an ``ETagCache``, keyed by request URL."""
64
+
65
+ etag: str
66
+ content: bytes
67
+ content_type: str
68
+
69
+
70
+ class ETagCache(Protocol):
71
+ """Store of the client's ``etag_cache`` option.
72
+
73
+ Entries never go stale: an ETag hashes its body, so a 304 proves the stored body is current.
74
+ """
75
+
76
+ def get(self, key: str) -> CachedResponse | None: ...
77
+
78
+ def set(self, key: str, value: CachedResponse) -> None: ...
79
+
80
+
81
+ class MemoryETagCache:
82
+ """Thread-safe in-memory ``ETagCache`` keeping the ``max_entries`` most recently used responses."""
83
+
84
+ def __init__(self, max_entries: int = 1000) -> None:
85
+ if max_entries < 1:
86
+ raise ValueError("max_entries must be positive")
87
+ self._max_entries = max_entries
88
+ self._entries: OrderedDict[str, CachedResponse] = OrderedDict()
89
+ self._lock = threading.Lock()
90
+
91
+ def __len__(self) -> int:
92
+ return len(self._entries)
93
+
94
+ def get(self, key: str) -> CachedResponse | None:
95
+ with self._lock:
96
+ entry = self._entries.get(key)
97
+ if entry is not None:
98
+ self._entries.move_to_end(key)
99
+ return entry
100
+
101
+ def set(self, key: str, value: CachedResponse) -> None:
102
+ with self._lock:
103
+ self._entries[key] = value
104
+ self._entries.move_to_end(key)
105
+ if len(self._entries) > self._max_entries:
106
+ self._entries.popitem(last=False)
107
+
108
+
109
+ def backoff(attempt: int) -> float:
110
+ """Exponential backoff with full jitter: random in [0, min(30 s, 0.5 s * 2**attempt)]."""
111
+ return random.uniform(0, min(MAX_DELAY, BASE_DELAY * 2**attempt))
112
+
113
+
114
+ QueryItems = list[tuple[str, str | int | float | bool | None]]
115
+
116
+
117
+ def _query_items(query: Mapping[str, Any] | None, exploded: Sequence[str]) -> QueryItems:
118
+ """Encodes query parameters: arrays are comma-separated unless the spec explodes them."""
119
+ items: QueryItems = []
120
+ for key, value in (query or {}).items():
121
+ if value is None:
122
+ continue
123
+ if isinstance(value, bool):
124
+ items.append((key, "true" if value else "false"))
125
+ elif isinstance(value, (list, tuple)):
126
+ if key in exploded:
127
+ items.extend((key, _scalar(v)) for v in value)
128
+ elif value:
129
+ items.append((key, ",".join(_scalar(v) for v in value)))
130
+ else:
131
+ items.append((key, _scalar(value)))
132
+ return items
133
+
134
+
135
+ def _scalar(value: Any) -> str:
136
+ if isinstance(value, bool):
137
+ return "true" if value else "false"
138
+ return str(value)
139
+
140
+
141
+ def _problem(response: httpx.Response) -> dict[str, Any]:
142
+ try:
143
+ body = response.json()
144
+ except (ValueError, json.JSONDecodeError):
145
+ body = None
146
+ if isinstance(body, dict) and isinstance(body.get("title"), str):
147
+ return {"type": "about:blank", "status": response.status_code, **body}
148
+ problem: dict[str, Any] = {"type": "about:blank", "title": response.reason_phrase or f"HTTP {response.status_code}", "status": response.status_code}
149
+ if response.text:
150
+ problem["detail"] = response.text[:500]
151
+ return problem
152
+
153
+
154
+ def _decode(content: bytes, content_type: str, kind: str) -> Any:
155
+ if kind == "none":
156
+ return None
157
+ if kind == "bytes":
158
+ return content
159
+ if kind == "text" or "json" not in content_type:
160
+ return content.decode()
161
+ return json.loads(content)
162
+
163
+
164
+ class _Attempt:
165
+ """Request state shared by the sync and async send loops: headers and the ETag cache entry."""
166
+
167
+ def __init__(self, core: _Core, method: str, url: str, params: QueryItems, headers: dict[str, str], if_none_match: str | None) -> None:
168
+ if if_none_match is not None:
169
+ headers["If-None-Match"] = if_none_match
170
+ # The cache revalidates only requests without a caller-chosen condition.
171
+ explicit = any(h.lower() == "if-none-match" for h in headers)
172
+ self.cache = core.etag_cache if method == "GET" and not explicit else None
173
+ self.key = str(httpx.URL(url, params=params or None))
174
+ self.cached = self.cache.get(self.key) if self.cache is not None else None
175
+ if self.cached is not None:
176
+ headers["If-None-Match"] = self.cached.etag
177
+ self.headers = headers
178
+
179
+ def result(self, response: httpx.Response, kind: str, rate_limit: RateLimitInfo | None) -> APIResponse[Any]:
180
+ """The response of a 304 or a 2xx answer."""
181
+ status = response.status_code
182
+ if status == 304:
183
+ if self.cached is not None:
184
+ data: Any = _decode(self.cached.content, self.cached.content_type, kind)
185
+ else:
186
+ data = NotModified(response.headers.get("etag"), response.headers, rate_limit)
187
+ return APIResponse(data, status, response.headers, rate_limit)
188
+ content_type = response.headers.get("content-type", "")
189
+ etag = response.headers.get("etag")
190
+ if self.cache is not None and etag and status == 200:
191
+ self.cache.set(self.key, CachedResponse(etag, response.content, content_type))
192
+ data = None if status == 204 else _decode(response.content, content_type, kind)
193
+ return APIResponse(data, status, response.headers, rate_limit)
194
+
195
+
196
+ class _Core:
197
+ def __init__(
198
+ self,
199
+ api_key: str | None,
200
+ base_url: str,
201
+ max_retries: int,
202
+ max_retry_after: float,
203
+ etag_cache: ETagCache | None,
204
+ ) -> None:
205
+ self.base_url = base_url.rstrip("/")
206
+ self._api_key = api_key
207
+ self.max_retries = max_retries
208
+ self.max_retry_after = max_retry_after
209
+ self.etag_cache = etag_cache
210
+
211
+ def _headers(self, has_body: bool, extra: Mapping[str, str] | None) -> dict[str, str]:
212
+ headers = {"Accept": "application/json, application/problem+json", "User-Agent": f"status302-python/{__version__}"}
213
+ if self._api_key:
214
+ headers["Authorization"] = f"Bearer {self._api_key}"
215
+ if has_body:
216
+ headers["Content-Type"] = "application/json"
217
+ if extra:
218
+ headers.update(extra)
219
+ return headers
220
+
221
+ def _url(self, path: str, path_params: Mapping[str, Any] | None) -> str:
222
+ for name, value in (path_params or {}).items():
223
+ path = path.replace("{" + name + "}", quote(str(value), safe=""))
224
+ if "{" in path:
225
+ raise TypeError(f"missing path parameter in {path}")
226
+ return self.base_url + path
227
+
228
+ def _retry_delay(self, status: int, idempotent: bool, attempt: int, retry_after: int | None) -> float | None:
229
+ """Seconds to wait before retrying, or None when the error is final."""
230
+ if attempt >= self.max_retries:
231
+ return None
232
+ # 429 means the request was refused before it ran, so any method may retry it.
233
+ retryable = status == 429 or (status >= 500 and idempotent) or (status == 503 and retry_after is not None)
234
+ if not retryable:
235
+ return None
236
+ if retry_after is not None:
237
+ return float(retry_after) if retry_after <= self.max_retry_after else None
238
+ return backoff(attempt)
239
+
240
+
241
+ class SyncTransport(_Core):
242
+ """Blocking transport over ``httpx.Client``."""
243
+
244
+ def __init__(
245
+ self,
246
+ api_key: str | None = None,
247
+ *,
248
+ base_url: str = DEFAULT_BASE_URL,
249
+ max_retries: int = 3,
250
+ max_retry_after: float = 60,
251
+ timeout: float = 30.0,
252
+ http_client: httpx.Client | None = None,
253
+ sleep: Callable[[float], None] = time.sleep,
254
+ etag_cache: ETagCache | None = None,
255
+ ) -> None:
256
+ super().__init__(api_key, base_url, max_retries, max_retry_after, etag_cache)
257
+ self._http = http_client or httpx.Client(timeout=timeout)
258
+ self._sleep = sleep
259
+
260
+ def close(self) -> None:
261
+ self._http.close()
262
+
263
+ def request(
264
+ self,
265
+ method: str,
266
+ path: str,
267
+ *,
268
+ path_params: Mapping[str, Any] | None = None,
269
+ query: Mapping[str, Any] | None = None,
270
+ body: Any = None,
271
+ headers: Mapping[str, str] | None = None,
272
+ kind: str = "json",
273
+ exploded: Sequence[str] = (),
274
+ if_none_match: str | None = None,
275
+ ) -> APIResponse[Any]:
276
+ """Sends a request and returns the decoded body with status, headers, ETag and rate limit state.
277
+
278
+ With ``if_none_match`` the data is ``NotModified`` while the representation still has that ETag.
279
+ """
280
+ return self._send(method.upper(), self._url(path, path_params), _query_items(query, exploded), body, headers, kind, if_none_match)
281
+
282
+ def get_path(self, path_and_query: str) -> Any:
283
+ """GETs a REST path returned by the API itself (a stream ``snapshot``), including its query string."""
284
+ if not path_and_query.startswith("/"):
285
+ raise ValueError("get_path expects a path starting with /")
286
+ return self._send("GET", self.base_url + path_and_query, [], None, None, "json", None).data
287
+
288
+ def _respond(
289
+ self,
290
+ method: str,
291
+ path: str,
292
+ path_params: Mapping[str, Any] | None,
293
+ query: Mapping[str, Any] | None,
294
+ body: Any,
295
+ kind: str,
296
+ exploded: Sequence[str],
297
+ if_none_match: str | None = None,
298
+ ) -> APIResponse[Any]:
299
+ return self.request(
300
+ method, path, path_params=path_params, query=query, body=body, kind=kind, exploded=exploded, if_none_match=if_none_match
301
+ )
302
+
303
+ def _call(
304
+ self,
305
+ method: str,
306
+ path: str,
307
+ path_params: Mapping[str, Any] | None,
308
+ query: Mapping[str, Any] | None,
309
+ body: Any,
310
+ kind: str,
311
+ exploded: Sequence[str],
312
+ if_none_match: str | None = None,
313
+ ) -> Any:
314
+ return self._respond(method, path, path_params, query, body, kind, exploded, if_none_match).data
315
+
316
+ def _send(
317
+ self, method: str, url: str, params: QueryItems, body: Any, extra: Mapping[str, str] | None, kind: str, if_none_match: str | None
318
+ ) -> APIResponse[Any]:
319
+ state = _Attempt(self, method, url, params, self._headers(body is not None, extra), if_none_match)
320
+ content = None if body is None else json.dumps(body).encode()
321
+ idempotent = method in IDEMPOTENT
322
+ attempt = 0
323
+ while True:
324
+ try:
325
+ response = self._http.request(method, url, params=params or None, headers=state.headers, content=content)
326
+ except httpx.TransportError as exc:
327
+ if idempotent and attempt < self.max_retries:
328
+ self._sleep(backoff(attempt))
329
+ attempt += 1
330
+ continue
331
+ raise APIConnectionError(f"{method} {url}: {exc}") from exc
332
+ rate_limit = parse_rate_limit(response.headers)
333
+ if response.is_success or response.status_code == 304:
334
+ return state.result(response, kind, rate_limit)
335
+ retry_after = parse_retry_after(response.headers.get("retry-after"))
336
+ error = error_for(response.status_code, _problem(response), response.headers, rate_limit, retry_after)
337
+ delay = self._retry_delay(response.status_code, idempotent, attempt, retry_after)
338
+ if delay is None:
339
+ raise error
340
+ self._sleep(delay)
341
+ attempt += 1
342
+
343
+
344
+ class AsyncTransport(_Core):
345
+ """Asyncio transport over ``httpx.AsyncClient``."""
346
+
347
+ def __init__(
348
+ self,
349
+ api_key: str | None = None,
350
+ *,
351
+ base_url: str = DEFAULT_BASE_URL,
352
+ max_retries: int = 3,
353
+ max_retry_after: float = 60,
354
+ timeout: float = 30.0,
355
+ http_client: httpx.AsyncClient | None = None,
356
+ sleep: Callable[[float], Any] = asyncio.sleep,
357
+ etag_cache: ETagCache | None = None,
358
+ ) -> None:
359
+ super().__init__(api_key, base_url, max_retries, max_retry_after, etag_cache)
360
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
361
+ self._sleep = sleep
362
+
363
+ async def aclose(self) -> None:
364
+ await self._http.aclose()
365
+
366
+ async def request(
367
+ self,
368
+ method: str,
369
+ path: str,
370
+ *,
371
+ path_params: Mapping[str, Any] | None = None,
372
+ query: Mapping[str, Any] | None = None,
373
+ body: Any = None,
374
+ headers: Mapping[str, str] | None = None,
375
+ kind: str = "json",
376
+ exploded: Sequence[str] = (),
377
+ if_none_match: str | None = None,
378
+ ) -> APIResponse[Any]:
379
+ """Sends a request and returns the decoded body with status, headers, ETag and rate limit state.
380
+
381
+ With ``if_none_match`` the data is ``NotModified`` while the representation still has that ETag.
382
+ """
383
+ return await self._send(method.upper(), self._url(path, path_params), _query_items(query, exploded), body, headers, kind, if_none_match)
384
+
385
+ async def get_path(self, path_and_query: str) -> Any:
386
+ """GETs a REST path returned by the API itself (a stream ``snapshot``), including its query string."""
387
+ if not path_and_query.startswith("/"):
388
+ raise ValueError("get_path expects a path starting with /")
389
+ return (await self._send("GET", self.base_url + path_and_query, [], None, None, "json", None)).data
390
+
391
+ async def _arespond(
392
+ self,
393
+ method: str,
394
+ path: str,
395
+ path_params: Mapping[str, Any] | None,
396
+ query: Mapping[str, Any] | None,
397
+ body: Any,
398
+ kind: str,
399
+ exploded: Sequence[str],
400
+ if_none_match: str | None = None,
401
+ ) -> APIResponse[Any]:
402
+ return await self.request(
403
+ method, path, path_params=path_params, query=query, body=body, kind=kind, exploded=exploded, if_none_match=if_none_match
404
+ )
405
+
406
+ async def _acall(
407
+ self,
408
+ method: str,
409
+ path: str,
410
+ path_params: Mapping[str, Any] | None,
411
+ query: Mapping[str, Any] | None,
412
+ body: Any,
413
+ kind: str,
414
+ exploded: Sequence[str],
415
+ if_none_match: str | None = None,
416
+ ) -> Any:
417
+ return (await self._arespond(method, path, path_params, query, body, kind, exploded, if_none_match)).data
418
+
419
+ async def _send(
420
+ self, method: str, url: str, params: QueryItems, body: Any, extra: Mapping[str, str] | None, kind: str, if_none_match: str | None
421
+ ) -> APIResponse[Any]:
422
+ state = _Attempt(self, method, url, params, self._headers(body is not None, extra), if_none_match)
423
+ content = None if body is None else json.dumps(body).encode()
424
+ idempotent = method in IDEMPOTENT
425
+ attempt = 0
426
+ while True:
427
+ try:
428
+ response = await self._http.request(method, url, params=params or None, headers=state.headers, content=content)
429
+ except httpx.TransportError as exc:
430
+ if idempotent and attempt < self.max_retries:
431
+ await self._sleep(backoff(attempt))
432
+ attempt += 1
433
+ continue
434
+ raise APIConnectionError(f"{method} {url}: {exc}") from exc
435
+ rate_limit = parse_rate_limit(response.headers)
436
+ if response.is_success or response.status_code == 304:
437
+ return state.result(response, kind, rate_limit)
438
+ retry_after = parse_retry_after(response.headers.get("retry-after"))
439
+ error = error_for(response.status_code, _problem(response), response.headers, rate_limit, retry_after)
440
+ delay = self._retry_delay(response.status_code, idempotent, attempt, retry_after)
441
+ if delay is None:
442
+ raise error
443
+ await self._sleep(delay)
444
+ attempt += 1
status302/_client.py ADDED
@@ -0,0 +1,100 @@
1
+ """The public clients: ``Status302`` (blocking) and ``AsyncStatus302`` (asyncio)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
6
+ from typing import Any
7
+
8
+ from ._operations import AsyncOperations, SyncOperations
9
+ from .stream import Status302Stream
10
+
11
+
12
+ def _advance(page: Any, kwargs: dict[str, Any]) -> bool:
13
+ """Sets the next page's ``cursor`` or ``before`` argument; False on the last page."""
14
+ cursor = page.get("nextCursor")
15
+ if cursor:
16
+ kwargs["cursor"] = cursor
17
+ return True
18
+ before = page.get("nextBefore")
19
+ if before is not None:
20
+ kwargs["before"] = before
21
+ return True
22
+ return False
23
+
24
+
25
+ class Status302(SyncOperations):
26
+ """Blocking Status302 API client.
27
+
28
+ >>> status302 = Status302(api_key=os.environ["STATUS302_API_KEY"])
29
+ >>> page = status302.list_events(country="US", limit=10)
30
+
31
+ ``etag_cache`` (e.g. ``MemoryETagCache()``) revalidates GET responses that carried an ``ETag``:
32
+ the next GET of the same URL sends ``If-None-Match`` and a 304 returns the stored body.
33
+ """
34
+
35
+ def __enter__(self) -> Status302:
36
+ return self
37
+
38
+ def __exit__(self, *exc: object) -> None:
39
+ self.close()
40
+
41
+ def pages(self, operation: Callable[..., Any], *args: Any, **kwargs: Any) -> Iterator[Any]:
42
+ """Yields every page of a paginated operation.
43
+
44
+ Follows ``nextCursor`` as ``cursor``, or ``nextBefore`` as ``before`` for the collections
45
+ paged by id (``list_alert_rules``, ``list_alert_events``). ``operation`` is a bound list
46
+ method of this client, e.g. ``status302.list_events``.
47
+ """
48
+ while True:
49
+ page = operation(*args, **kwargs)
50
+ yield page
51
+ if not _advance(page, kwargs):
52
+ return
53
+
54
+ def paginate(self, operation: Callable[..., Any], *args: Any, **kwargs: Any) -> Iterator[Any]:
55
+ """Yields every item of a cursor-paginated operation across pages.
56
+
57
+ >>> for event in status302.paginate(status302.list_events, country="US"):
58
+ ... print(event["name"])
59
+ """
60
+ for page in self.pages(operation, *args, **kwargs):
61
+ yield from page["items"]
62
+
63
+
64
+ class AsyncStatus302(AsyncOperations):
65
+ """Asyncio Status302 API client; also creates stream connections.
66
+
67
+ >>> async with AsyncStatus302(api_key=os.environ["STATUS302_API_KEY"]) as status302:
68
+ ... page = await status302.list_events(limit=10)
69
+
70
+ ``etag_cache`` works as on ``Status302``.
71
+ """
72
+
73
+ async def __aenter__(self) -> AsyncStatus302:
74
+ return self
75
+
76
+ async def __aexit__(self, *exc: object) -> None:
77
+ await self.aclose()
78
+
79
+ async def pages(self, operation: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> AsyncIterator[Any]:
80
+ """Yields every page of a paginated operation, following ``nextCursor`` or ``nextBefore``."""
81
+ while True:
82
+ page = await operation(*args, **kwargs)
83
+ yield page
84
+ if not _advance(page, kwargs):
85
+ return
86
+
87
+ async def paginate(self, operation: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> AsyncIterator[Any]:
88
+ """Yields every item of a cursor-paginated operation across pages."""
89
+ async for page in self.pages(operation, *args, **kwargs):
90
+ for item in page["items"]:
91
+ yield item
92
+
93
+ async def stream_ticket(self) -> str:
94
+ """A single-use stream ticket (60 s) for a browser connection."""
95
+ return (await self.create_stream_ticket())["ticket"]
96
+
97
+ def stream(self, **options: Any) -> Status302Stream:
98
+ """A realtime stream authenticated with this client's API key (see ``Status302Stream``)."""
99
+ options.setdefault("api_key", self._api_key)
100
+ return Status302Stream(client=self, **options)