zapsmtp 0.1.0b1__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.
zapsmtp/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """The official Python library for the ZapSMTP email API.
2
+
3
+ from zapsmtp import ZapSMTP
4
+
5
+ client = ZapSMTP() # reads ZAP_API_KEY
6
+ client.emails.send(from_="Acme <hello@acme.com>", to="jane@example.org", subject="Hi", text="Hello")
7
+ """
8
+
9
+ from . import types
10
+ from ._attachments import attachment, attachment_from_file
11
+ from ._base_client import AsyncAPIClient, SyncAPIClient
12
+ from ._exceptions import (
13
+ AuthenticationError,
14
+ ConflictError,
15
+ ConnectionError,
16
+ NotFoundError,
17
+ PermissionError,
18
+ RateLimitError,
19
+ ServerError,
20
+ ValidationError,
21
+ WebhookVerificationError,
22
+ ZapSMTPError,
23
+ )
24
+ from ._generated.client import AsyncZapSMTP, ZapSMTP
25
+ from ._models import BaseModel
26
+ from ._pagination import AsyncPage, AsyncPageRequest, SyncPage
27
+ from ._response import APIResponse
28
+ from ._types import NOT_GIVEN, NotGiven
29
+ from ._version import __version__
30
+ from ._webhooks import WebhookEvent, verify_webhook, verify_webhook_events
31
+
32
+ __all__ = [
33
+ "APIResponse",
34
+ "AsyncAPIClient",
35
+ "AsyncPage",
36
+ "AsyncPageRequest",
37
+ "AsyncZapSMTP",
38
+ "AuthenticationError",
39
+ "BaseModel",
40
+ "ConflictError",
41
+ "ConnectionError",
42
+ "NOT_GIVEN",
43
+ "NotFoundError",
44
+ "NotGiven",
45
+ "PermissionError",
46
+ "RateLimitError",
47
+ "ServerError",
48
+ "SyncAPIClient",
49
+ "SyncPage",
50
+ "ValidationError",
51
+ "WebhookEvent",
52
+ "WebhookVerificationError",
53
+ "ZapSMTP",
54
+ "ZapSMTPError",
55
+ "__version__",
56
+ "attachment",
57
+ "attachment_from_file",
58
+ "types",
59
+ "verify_webhook",
60
+ "verify_webhook_events",
61
+ ]
@@ -0,0 +1,65 @@
1
+ """Attachment helpers: the content is sent base64-encoded."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ from typing import Optional, Union
8
+
9
+ from ._generated.params import OutgoingAttachment
10
+
11
+ __all__ = ["attachment", "attachment_from_file"]
12
+
13
+ _TYPES = {
14
+ ".pdf": "application/pdf",
15
+ ".png": "image/png",
16
+ ".jpg": "image/jpeg",
17
+ ".jpeg": "image/jpeg",
18
+ ".gif": "image/gif",
19
+ ".webp": "image/webp",
20
+ ".svg": "image/svg+xml",
21
+ ".txt": "text/plain",
22
+ ".csv": "text/csv",
23
+ ".html": "text/html",
24
+ ".ics": "text/calendar",
25
+ ".json": "application/json",
26
+ ".xml": "application/xml",
27
+ ".zip": "application/zip",
28
+ ".doc": "application/msword",
29
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
30
+ ".xls": "application/vnd.ms-excel",
31
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
32
+ }
33
+
34
+
35
+ def attachment(
36
+ filename: str,
37
+ content: Union[bytes, bytearray, memoryview, str],
38
+ content_type: Optional[str] = None,
39
+ content_id: Optional[str] = None,
40
+ ) -> OutgoingAttachment:
41
+ """An attachment from bytes or text (text is sent as UTF-8).
42
+
43
+ The content type follows the file name's extension unless given.
44
+ ``content_id`` makes it inline: reference it as ``cid:<content_id>``.
45
+ """
46
+ data = content.encode("utf-8") if isinstance(content, str) else bytes(content)
47
+ result: OutgoingAttachment = {"filename": filename, "content": base64.b64encode(data).decode("ascii")}
48
+ kind = content_type if content_type is not None else _TYPES.get(os.path.splitext(filename)[1].lower())
49
+ if kind:
50
+ result["content_type"] = kind
51
+ if content_id:
52
+ result["content_id"] = content_id
53
+ return result
54
+
55
+
56
+ def attachment_from_file(
57
+ path: Union[str, "os.PathLike[str]"],
58
+ filename: Optional[str] = None,
59
+ content_type: Optional[str] = None,
60
+ content_id: Optional[str] = None,
61
+ ) -> OutgoingAttachment:
62
+ """An attachment read from a file; ``filename`` defaults to the file's name."""
63
+ with open(path, "rb") as f:
64
+ data = f.read()
65
+ return attachment(filename if filename is not None else os.path.basename(os.fspath(path)), data, content_type, content_id)
@@ -0,0 +1,501 @@
1
+ """The hand-written core of the SDK: HTTP, retries, errors, paging.
2
+
3
+ The API resources and models are generated (``_generated``) from the API
4
+ reference; they call :meth:`SyncAPIClient._request` and friends.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import platform
13
+ import time
14
+ import uuid
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Dict, Mapping, Optional, Tuple, Type, TypeVar, Union
17
+
18
+ import httpx
19
+
20
+ from ._exceptions import ConnectionError, ValidationError, ZapSMTPError, error_for
21
+ from ._models import BaseModel, construct
22
+ from ._pagination import AsyncPage, AsyncPageRequest, Query, SyncPage
23
+ from ._response import ResponseInfo, record
24
+ from ._retry import backoff, retry_after_seconds, retryable
25
+ from ._types import NOT_GIVEN, NotGiven, Timeout, TimeoutArg
26
+ from ._utils import MultipartForm, dump_json, encode_query, expand_path, loads, strip
27
+ from ._version import __version__
28
+
29
+ __all__ = ["AsyncAPIClient", "RequestOptions", "SyncAPIClient"]
30
+
31
+ DEFAULT_BASE_URL = "https://api.zapsmtp.com/v1"
32
+ DEFAULT_TIMEOUT = 60.0
33
+ DEFAULT_MAX_RETRIES = 2
34
+
35
+ M = TypeVar("M", bound=BaseModel)
36
+ SyncClientT = TypeVar("SyncClientT", bound="SyncAPIClient")
37
+ AsyncClientT = TypeVar("AsyncClientT", bound="AsyncAPIClient")
38
+
39
+ _SAFE_METHODS = frozenset({"GET", "PUT", "DELETE"})
40
+
41
+
42
+ def _sleep(seconds: float) -> None:
43
+ time.sleep(seconds)
44
+
45
+
46
+ async def _async_sleep(seconds: float) -> None:
47
+ await asyncio.sleep(seconds)
48
+
49
+
50
+ def _monotonic() -> float:
51
+ return time.monotonic()
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class RequestOptions:
56
+ """Per-request settings every generated method takes as keyword arguments."""
57
+
58
+ idempotency_key: Optional[str] = None
59
+ extra_headers: Optional[Mapping[str, str]] = None
60
+ timeout: TimeoutArg = NOT_GIVEN
61
+ max_retries: Optional[int] = None
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class _Request:
66
+ method: str
67
+ path: str
68
+ query: Query = field(default_factory=dict)
69
+ body: Any = NOT_GIVEN
70
+ form: Optional[MultipartForm] = None
71
+ headers: Mapping[str, Optional[str]] = field(default_factory=dict)
72
+ idempotent: bool = False
73
+ path_params: Mapping[str, Any] = field(default_factory=dict)
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class _Prepared:
78
+ method: str
79
+ url: str
80
+ headers: httpx.Headers
81
+ content: Optional[bytes]
82
+ form: Optional[MultipartForm]
83
+ timeout: Timeout
84
+ max_retries: int
85
+ safe: bool
86
+
87
+
88
+ class _AttemptTimeout(Exception):
89
+ pass
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class _Answer:
94
+ status: int
95
+ headers: httpx.Headers
96
+ text: str
97
+
98
+
99
+ def _request(
100
+ method: str,
101
+ path: str,
102
+ query: Optional[Query],
103
+ body: Any,
104
+ form: Optional[MultipartForm],
105
+ headers: Optional[Mapping[str, Optional[str]]],
106
+ idempotent: bool,
107
+ path_params: Optional[Mapping[str, Any]],
108
+ ) -> _Request:
109
+ return _Request(
110
+ method,
111
+ path,
112
+ strip(query or {}),
113
+ strip(body) if isinstance(body, dict) else body,
114
+ form,
115
+ headers or {},
116
+ idempotent,
117
+ path_params or {},
118
+ )
119
+
120
+
121
+ def _user_agent() -> str:
122
+ return f"zapsmtp-python/{__version__} (python/{platform.python_version()})"
123
+
124
+
125
+ def _decode(answer: _Answer) -> Any:
126
+ if not answer.text:
127
+ return None
128
+ try:
129
+ return loads(answer.text)
130
+ except ValueError:
131
+ return answer.text
132
+
133
+
134
+ def _connection_error(err: Exception, timeout: Timeout) -> ConnectionError:
135
+ if isinstance(err, (_AttemptTimeout, httpx.TimeoutException)):
136
+ return ConnectionError(f"timed out after {timeout:g} s" if timeout is not None else "timed out")
137
+ return ConnectionError(str(err) or type(err).__name__)
138
+
139
+
140
+ class _BaseClient:
141
+ api_key: str
142
+ base_url: str
143
+ timeout: Timeout
144
+ max_retries: int
145
+
146
+ def __init__(
147
+ self,
148
+ api_key: Optional[str],
149
+ *,
150
+ base_url: Optional[str],
151
+ timeout: Timeout,
152
+ max_retries: int,
153
+ default_headers: Optional[Mapping[str, str]],
154
+ ) -> None:
155
+ key = api_key if api_key is not None else os.environ.get("ZAP_API_KEY")
156
+ if not key:
157
+ raise ZapSMTPError("Missing API key: pass api_key=... or set ZAP_API_KEY")
158
+ self.api_key = key
159
+ self.base_url = (base_url or os.environ.get("ZAP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
160
+ self.timeout = timeout
161
+ self.max_retries = max_retries
162
+ self._default_headers: Dict[str, str] = dict(default_headers or {})
163
+
164
+ def __repr__(self) -> str:
165
+ return f"{type(self).__name__}(base_url={self.base_url!r})"
166
+
167
+ def _prepare(self, req: _Request, options: RequestOptions) -> _Prepared:
168
+ headers = httpx.Headers(
169
+ {"Authorization": f"Bearer {self.api_key}", "Accept": "application/json", "User-Agent": _user_agent()}
170
+ )
171
+ headers.update(self._default_headers)
172
+ headers.update({k: v for k, v in req.headers.items() if v is not None})
173
+ if req.idempotent:
174
+ key = options.idempotency_key
175
+ headers["Idempotency-Key"] = str(uuid.uuid4()) if key is None else key
176
+ headers.update(options.extra_headers or {}) # the caller's headers replace ours, whatever their case
177
+ # an empty key turns deduplication off on the server, so a retried send
178
+ # could go out twice: refused before anything is sent
179
+ key = headers.get("idempotency-key")
180
+ if key is not None and not key.strip():
181
+ raise ValidationError("idempotency_key must not be empty", code="invalid_parameter")
182
+ content: Optional[bytes] = None
183
+ if req.form is None and not isinstance(req.body, NotGiven):
184
+ headers["Content-Type"] = "application/json"
185
+ content = dump_json(req.body)
186
+ query = encode_query(req.query)
187
+ timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout
188
+ return _Prepared(
189
+ method=req.method,
190
+ url=self.base_url + expand_path(req.path, req.path_params) + (f"?{query}" if query else ""),
191
+ headers=headers,
192
+ content=content,
193
+ form=req.form,
194
+ timeout=timeout,
195
+ max_retries=self.max_retries if options.max_retries is None else options.max_retries,
196
+ safe=req.method in _SAFE_METHODS or req.idempotent,
197
+ )
198
+
199
+ def _build(self, client: Union[httpx.Client, httpx.AsyncClient], p: _Prepared) -> httpx.Request:
200
+ if p.form is not None:
201
+ return client.build_request(
202
+ p.method,
203
+ p.url,
204
+ headers=p.headers,
205
+ files={p.form.field: (p.form.filename, p.form.content)},
206
+ data=p.form.fields,
207
+ timeout=p.timeout,
208
+ )
209
+ return client.build_request(p.method, p.url, headers=p.headers, content=p.content, timeout=p.timeout)
210
+
211
+ @staticmethod
212
+ def _deadline(p: _Prepared) -> Optional[float]:
213
+ return None if p.timeout is None else _monotonic() + p.timeout
214
+
215
+ @staticmethod
216
+ def _check(deadline: Optional[float]) -> None:
217
+ # the timeout bounds the whole attempt, the body read included
218
+ if deadline is not None and _monotonic() > deadline:
219
+ raise _AttemptTimeout()
220
+
221
+ @classmethod
222
+ def _checked(cls, chunk: bytes, deadline: Optional[float]) -> bytes:
223
+ cls._check(deadline)
224
+ return chunk
225
+
226
+ @staticmethod
227
+ def _answer(response: httpx.Response, body: bytes) -> _Answer:
228
+ return _Answer(response.status_code, response.headers, body.decode(response.charset_encoding or "utf-8", "replace"))
229
+
230
+ @staticmethod
231
+ def _outcome(answer: _Answer) -> Tuple[Any, ResponseInfo, Optional[ZapSMTPError]]:
232
+ info = ResponseInfo(answer.status, answer.headers, answer.headers.get("x-request-id"))
233
+ parsed = _decode(answer)
234
+ if 200 <= answer.status < 300:
235
+ record(info)
236
+ return parsed, info, None
237
+ return parsed, info, error_for(answer.status, parsed, answer.headers)
238
+
239
+
240
+ class SyncAPIClient(_BaseClient):
241
+ """The synchronous client core. Use :class:`zapsmtp.ZapSMTP`."""
242
+
243
+ def __init__(
244
+ self,
245
+ api_key: Optional[str] = None,
246
+ *,
247
+ base_url: Optional[str] = None,
248
+ timeout: Timeout = DEFAULT_TIMEOUT,
249
+ max_retries: int = DEFAULT_MAX_RETRIES,
250
+ default_headers: Optional[Mapping[str, str]] = None,
251
+ http_client: Optional[httpx.Client] = None,
252
+ ) -> None:
253
+ super().__init__(
254
+ api_key, base_url=base_url, timeout=timeout, max_retries=max_retries, default_headers=default_headers
255
+ )
256
+ self._owns_client = http_client is None
257
+ self._client = http_client if http_client is not None else httpx.Client()
258
+
259
+ def with_options(
260
+ self: SyncClientT,
261
+ *,
262
+ api_key: Optional[str] = None,
263
+ base_url: Optional[str] = None,
264
+ timeout: TimeoutArg = NOT_GIVEN,
265
+ max_retries: Optional[int] = None,
266
+ default_headers: Optional[Mapping[str, str]] = None,
267
+ ) -> SyncClientT:
268
+ """A copy of this client with some settings changed; it shares the connection pool."""
269
+ return type(self)(
270
+ api_key or self.api_key,
271
+ base_url=base_url or self.base_url,
272
+ timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
273
+ max_retries=self.max_retries if max_retries is None else max_retries,
274
+ default_headers={**self._default_headers, **(default_headers or {})},
275
+ http_client=self._client,
276
+ )
277
+
278
+ def close(self) -> None:
279
+ """Closes the connection pool, unless it was passed in as ``http_client``."""
280
+ if self._owns_client:
281
+ self._client.close()
282
+
283
+ def __enter__(self: SyncClientT) -> SyncClientT:
284
+ return self
285
+
286
+ def __exit__(self, *exc: object) -> None:
287
+ self.close()
288
+
289
+ def _attempt(self, p: _Prepared) -> _Answer:
290
+ deadline = self._deadline(p)
291
+ response = self._client.send(self._build(self._client, p), stream=True)
292
+ try:
293
+ self._check(deadline)
294
+ chunks = [self._checked(chunk, deadline) for chunk in response.iter_bytes()]
295
+ self._check(deadline) # the end of the body too, not only its chunks
296
+ return self._answer(response, b"".join(chunks))
297
+ finally:
298
+ response.close()
299
+
300
+ def _send(self, req: _Request, options: RequestOptions) -> Any:
301
+ p = self._prepare(req, options)
302
+ attempt = -1
303
+ while True:
304
+ attempt += 1
305
+ try:
306
+ answer = self._attempt(p)
307
+ except Exception as err:
308
+ # a timeout or a broken connection (sending, or reading the
309
+ # answer): the request may have reached the server, so only a
310
+ # request that is safe to repeat is sent again
311
+ if attempt < p.max_retries and p.safe:
312
+ _sleep(backoff(attempt, None))
313
+ continue
314
+ raise _connection_error(err, p.timeout) from err
315
+ parsed, info, error = self._outcome(answer)
316
+ if error is None:
317
+ return parsed
318
+ if attempt < p.max_retries and retryable(info.status, error.code, p.safe):
319
+ _sleep(backoff(attempt, retry_after_seconds(info.headers)))
320
+ continue
321
+ raise error
322
+
323
+ def _request(
324
+ self,
325
+ method: str,
326
+ path: str,
327
+ *,
328
+ cast: Type[M],
329
+ options: RequestOptions,
330
+ path_params: Optional[Mapping[str, Any]] = None,
331
+ query: Optional[Query] = None,
332
+ body: Any = NOT_GIVEN,
333
+ form: Optional[MultipartForm] = None,
334
+ headers: Optional[Mapping[str, Optional[str]]] = None,
335
+ idempotent: bool = False,
336
+ ) -> M:
337
+ result: M = construct(cast, self._send(_request(method, path, query, body, form, headers, idempotent, path_params), options))
338
+ return result
339
+
340
+ def _request_empty(
341
+ self,
342
+ method: str,
343
+ path: str,
344
+ *,
345
+ options: RequestOptions,
346
+ path_params: Optional[Mapping[str, Any]] = None,
347
+ query: Optional[Query] = None,
348
+ body: Any = NOT_GIVEN,
349
+ headers: Optional[Mapping[str, Optional[str]]] = None,
350
+ idempotent: bool = False,
351
+ ) -> None:
352
+ self._send(_request(method, path, query, body, None, headers, idempotent, path_params), options)
353
+
354
+ def _request_page(
355
+ self,
356
+ method: str,
357
+ path: str,
358
+ *,
359
+ cast: Type[M],
360
+ paging: Optional[str],
361
+ options: RequestOptions,
362
+ path_params: Optional[Mapping[str, Any]] = None,
363
+ query: Optional[Query] = None,
364
+ ) -> SyncPage[M]:
365
+ def load(q: Query) -> SyncPage[M]:
366
+ req = _request(method, path, q, NOT_GIVEN, None, None, False, path_params)
367
+ return SyncPage(self._send(req, options), cast, req.query, paging, load)
368
+
369
+ return load(strip(query or {}))
370
+
371
+
372
+ class AsyncAPIClient(_BaseClient):
373
+ """The asyncio client core. Use :class:`zapsmtp.AsyncZapSMTP`."""
374
+
375
+ def __init__(
376
+ self,
377
+ api_key: Optional[str] = None,
378
+ *,
379
+ base_url: Optional[str] = None,
380
+ timeout: Timeout = DEFAULT_TIMEOUT,
381
+ max_retries: int = DEFAULT_MAX_RETRIES,
382
+ default_headers: Optional[Mapping[str, str]] = None,
383
+ http_client: Optional[httpx.AsyncClient] = None,
384
+ ) -> None:
385
+ super().__init__(
386
+ api_key, base_url=base_url, timeout=timeout, max_retries=max_retries, default_headers=default_headers
387
+ )
388
+ self._owns_client = http_client is None
389
+ self._client = http_client if http_client is not None else httpx.AsyncClient()
390
+
391
+ def with_options(
392
+ self: AsyncClientT,
393
+ *,
394
+ api_key: Optional[str] = None,
395
+ base_url: Optional[str] = None,
396
+ timeout: TimeoutArg = NOT_GIVEN,
397
+ max_retries: Optional[int] = None,
398
+ default_headers: Optional[Mapping[str, str]] = None,
399
+ ) -> AsyncClientT:
400
+ """A copy of this client with some settings changed; it shares the connection pool."""
401
+ return type(self)(
402
+ api_key or self.api_key,
403
+ base_url=base_url or self.base_url,
404
+ timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
405
+ max_retries=self.max_retries if max_retries is None else max_retries,
406
+ default_headers={**self._default_headers, **(default_headers or {})},
407
+ http_client=self._client,
408
+ )
409
+
410
+ async def close(self) -> None:
411
+ """Closes the connection pool, unless it was passed in as ``http_client``."""
412
+ if self._owns_client:
413
+ await self._client.aclose()
414
+
415
+ async def __aenter__(self: AsyncClientT) -> AsyncClientT:
416
+ return self
417
+
418
+ async def __aexit__(self, *exc: object) -> None:
419
+ await self.close()
420
+
421
+ async def _attempt(self, p: _Prepared) -> _Answer:
422
+ deadline = self._deadline(p)
423
+ response = await self._client.send(self._build(self._client, p), stream=True)
424
+ try:
425
+ self._check(deadline)
426
+ chunks = [self._checked(chunk, deadline) async for chunk in response.aiter_bytes()]
427
+ self._check(deadline) # the end of the body too, not only its chunks
428
+ return self._answer(response, b"".join(chunks))
429
+ finally:
430
+ await response.aclose()
431
+
432
+ async def _send(self, req: _Request, options: RequestOptions) -> Any:
433
+ p = self._prepare(req, options)
434
+ attempt = -1
435
+ while True:
436
+ attempt += 1
437
+ try:
438
+ answer = await self._attempt(p)
439
+ except Exception as err:
440
+ # see SyncAPIClient._send; cancellation (asyncio.CancelledError)
441
+ # is not an Exception and goes straight through
442
+ if attempt < p.max_retries and p.safe:
443
+ await _async_sleep(backoff(attempt, None))
444
+ continue
445
+ raise _connection_error(err, p.timeout) from err
446
+ parsed, info, error = self._outcome(answer)
447
+ if error is None:
448
+ return parsed
449
+ if attempt < p.max_retries and retryable(info.status, error.code, p.safe):
450
+ await _async_sleep(backoff(attempt, retry_after_seconds(info.headers)))
451
+ continue
452
+ raise error
453
+
454
+ async def _request(
455
+ self,
456
+ method: str,
457
+ path: str,
458
+ *,
459
+ cast: Type[M],
460
+ options: RequestOptions,
461
+ path_params: Optional[Mapping[str, Any]] = None,
462
+ query: Optional[Query] = None,
463
+ body: Any = NOT_GIVEN,
464
+ form: Optional[MultipartForm] = None,
465
+ headers: Optional[Mapping[str, Optional[str]]] = None,
466
+ idempotent: bool = False,
467
+ ) -> M:
468
+ result: M = construct(cast, await self._send(_request(method, path, query, body, form, headers, idempotent, path_params), options))
469
+ return result
470
+
471
+ async def _request_empty(
472
+ self,
473
+ method: str,
474
+ path: str,
475
+ *,
476
+ options: RequestOptions,
477
+ path_params: Optional[Mapping[str, Any]] = None,
478
+ query: Optional[Query] = None,
479
+ body: Any = NOT_GIVEN,
480
+ headers: Optional[Mapping[str, Optional[str]]] = None,
481
+ idempotent: bool = False,
482
+ ) -> None:
483
+ await self._send(_request(method, path, query, body, None, headers, idempotent, path_params), options)
484
+
485
+ def _request_page(
486
+ self,
487
+ method: str,
488
+ path: str,
489
+ *,
490
+ cast: Type[M],
491
+ paging: Optional[str],
492
+ options: RequestOptions,
493
+ path_params: Optional[Mapping[str, Any]] = None,
494
+ query: Optional[Query] = None,
495
+ ) -> AsyncPageRequest[M]:
496
+ async def load(q: Query) -> AsyncPage[M]:
497
+ req = _request(method, path, q, NOT_GIVEN, None, None, False, path_params)
498
+ return AsyncPage(await self._send(req, options), cast, req.query, paging, load)
499
+
500
+ first = strip(query or {})
501
+ return AsyncPageRequest(lambda: load(first))