mobilevalidate-sdk 1.0.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.
@@ -0,0 +1,65 @@
1
+ """MobileValidate API client for Python — know before you send.
2
+
3
+ >>> from mobilevalidate import MobileValidate
4
+ >>> mv = MobileValidate(sandbox=True) # or MobileValidate() with MOBILEVALIDATE_API_KEY set
5
+ >>> mv.lookup("+447700900001", checks=["whatsapp"])["results"][0]["checks"]["whatsapp.registered"]["registered"]
6
+ True
7
+ """
8
+
9
+ from ._client import AsyncMobileValidate, MobileValidate
10
+ from ._constants import (
11
+ DEFAULT_BASE_URL,
12
+ SANDBOX_LIMITS,
13
+ SANDBOX_PUBLIC_KEY,
14
+ TEST_EMAIL_DOMAIN,
15
+ TEST_EMAILS,
16
+ TEST_NUMBERS,
17
+ __version__,
18
+ )
19
+ from ._errors import (
20
+ APIConnectionError,
21
+ APIError,
22
+ APITimeoutError,
23
+ AuthenticationError,
24
+ CostLimitExceededError,
25
+ DailyCapReachedError,
26
+ IdempotencyKeyReusedError,
27
+ IdempotencyRequestInProgressError,
28
+ InsufficientBalanceError,
29
+ InsufficientScopeError,
30
+ InternalServerError,
31
+ InvalidArgumentError,
32
+ InvalidCursorError,
33
+ InvalidRequestError,
34
+ InvalidResponseError,
35
+ MissingApiKeyError,
36
+ MobileValidateError,
37
+ NotFoundError,
38
+ PayloadTooLargeError,
39
+ RateLimitedError,
40
+ RateLimitError,
41
+ SandboxMagicOnlyError,
42
+ ServiceDisabledError,
43
+ SpendCapReachedError,
44
+ SuspectedEnumerationError,
45
+ TemporarilyUnavailableError,
46
+ TestKeyExistsError,
47
+ TestNumberOnlyError,
48
+ TooManyNumbersError,
49
+ UnauthorizedError,
50
+ WebhookVerificationError,
51
+ )
52
+ from .types import APIObject
53
+ from .webhooks import sign_webhook, verify_webhook
54
+
55
+ __all__ = [
56
+ "MobileValidate", "AsyncMobileValidate", "APIObject", "verify_webhook", "sign_webhook", "__version__",
57
+ "DEFAULT_BASE_URL", "SANDBOX_PUBLIC_KEY", "SANDBOX_LIMITS", "TEST_NUMBERS", "TEST_EMAILS", "TEST_EMAIL_DOMAIN",
58
+ "MobileValidateError", "APIError", "InvalidRequestError", "TooManyNumbersError", "TestNumberOnlyError",
59
+ "InvalidCursorError", "UnauthorizedError", "AuthenticationError", "InsufficientBalanceError",
60
+ "CostLimitExceededError", "InsufficientScopeError", "ServiceDisabledError", "SandboxMagicOnlyError",
61
+ "SuspectedEnumerationError", "NotFoundError", "IdempotencyKeyReusedError", "IdempotencyRequestInProgressError",
62
+ "TestKeyExistsError", "PayloadTooLargeError", "RateLimitedError", "RateLimitError", "DailyCapReachedError",
63
+ "SpendCapReachedError", "InternalServerError", "TemporarilyUnavailableError", "InvalidResponseError",
64
+ "APIConnectionError", "APITimeoutError", "MissingApiKeyError", "InvalidArgumentError", "WebhookVerificationError",
65
+ ]
@@ -0,0 +1,612 @@
1
+ """MobileValidate clients: :class:`MobileValidate` (sync) and :class:`AsyncMobileValidate` (asyncio)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import random
7
+ import re
8
+ import time
9
+ from typing import Any, AsyncIterator, Dict, Iterator, List, Mapping, Optional, Sequence, Union
10
+ from urllib.parse import quote
11
+
12
+ import httpx
13
+
14
+ from ._constants import DEFAULT_BASE_URL, SANDBOX_PUBLIC_KEY
15
+ from ._errors import InvalidArgumentError, MobileValidateError
16
+ from ._http import AsyncTransport, SyncTransport
17
+ from .types import APIObject, DownloadFormat, DurationInput, MoneyInput
18
+ from .webhooks import DEFAULT_TOLERANCE_SECONDS, verify_webhook
19
+
20
+ __all__ = ["MobileValidate", "AsyncMobileValidate"]
21
+
22
+ MAX_SERVER_WAIT_S = 30
23
+ TERMINAL_JOB_STATUSES = frozenset({"completed", "failed", "cancelled"})
24
+ _UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
25
+
26
+ Numbers = Union[str, Sequence[str], None]
27
+
28
+
29
+ # ---------------------------------------------------------------- helpers (shared by sync + async)
30
+
31
+ def to_money(v: MoneyInput) -> Dict[str, str]:
32
+ """``"0.05"`` / ``0.05`` / ``{"amount": "0.05", "currency": "USD"}`` → Money dict (decimal string, never a float)."""
33
+ if isinstance(v, dict):
34
+ return dict(v) # type: ignore[arg-type]
35
+ if isinstance(v, bool):
36
+ raise InvalidArgumentError("max_cost must be a non-negative decimal", param="max_cost")
37
+ amount = format(v, "f").rstrip("0").rstrip(".") if isinstance(v, float) else str(v).strip()
38
+ if not re.fullmatch(r"\d+(\.\d+)?", amount or ""):
39
+ raise InvalidArgumentError("max_cost must be a non-negative decimal", param="max_cost")
40
+ return {"amount": amount, "currency": "USD"}
41
+
42
+
43
+ def to_seconds(v: DurationInput) -> int:
44
+ """Seconds, or ``"30s"``, ``"15m"``, ``"24h"``, ``"7d"`` → integer seconds."""
45
+ if isinstance(v, bool):
46
+ raise InvalidArgumentError("max_age must be seconds or like 30s, 15m, 24h, 7d", param="max_age")
47
+ if isinstance(v, (int, float)):
48
+ return max(0, int(v))
49
+ m = re.fullmatch(r"(\d+)\s*([smhd]?)", str(v).strip())
50
+ if not m:
51
+ raise InvalidArgumentError("max_age must be seconds or like 30s, 15m, 24h, 7d", param="max_age")
52
+ return int(m.group(1)) * _UNITS[m.group(2) or "s"]
53
+
54
+
55
+ def _clamp_wait(w: Optional[float], default: int) -> int:
56
+ return min(MAX_SERVER_WAIT_S, max(0, int(default if w is None else w)))
57
+
58
+
59
+ _ACCEPT = {"csv": "text/csv", "ndjson": "application/x-ndjson"}
60
+
61
+
62
+ def _fmt(format: str) -> str:
63
+ if format not in _ACCEPT:
64
+ raise InvalidArgumentError("format must be csv or ndjson", param="format")
65
+ return format
66
+
67
+
68
+ def _compact(d: Mapping[str, Any]) -> Dict[str, Any]:
69
+ return {k: v for k, v in d.items() if v is not None}
70
+
71
+
72
+ def _as_list(v: Numbers) -> Optional[List[str]]:
73
+ if v is None:
74
+ return None
75
+ return [v] if isinstance(v, str) else list(v)
76
+
77
+
78
+ def _registered_param(v: Any) -> Optional[str]:
79
+ if v is None:
80
+ return None
81
+ if isinstance(v, bool):
82
+ return "true" if v else "false"
83
+ if v in ("true", "false", "null"):
84
+ return str(v)
85
+ raise InvalidArgumentError("registered must be True, False, 'true', 'false' or 'null'", param="registered")
86
+
87
+
88
+ def _p(s: str) -> str:
89
+ return quote(s, safe="")
90
+
91
+
92
+ def _lookup_body(numbers: Optional[List[str]], emails: Optional[List[str]], wait: int, o: Dict[str, Any]) -> Dict[str, Any]:
93
+ if not numbers and not emails:
94
+ raise InvalidArgumentError("Provide at least one number or e-mail address", param="numbers")
95
+ return _compact({
96
+ "numbers": numbers,
97
+ "emails": emails,
98
+ "checks": list(o["checks"]) if o.get("checks") is not None else None,
99
+ "default_country": o.get("default_country"),
100
+ "max_age": None if o.get("max_age") is None else to_seconds(o["max_age"]),
101
+ "wait": wait,
102
+ "max_cost": None if o.get("max_cost") is None else to_money(o["max_cost"]),
103
+ "metadata": o.get("metadata"),
104
+ "webhook_endpoint_id": o.get("webhook_endpoint_id"),
105
+ })
106
+
107
+
108
+ def _job_body(
109
+ numbers: Numbers, emails: Numbers, upload_id: Optional[str], checks: Optional[Sequence[str]],
110
+ default_country: Optional[str], max_age: Optional[DurationInput], max_cost: Optional[MoneyInput],
111
+ webhook_endpoint_id: Optional[str], metadata: Optional[Dict[str, str]],
112
+ ) -> Dict[str, Any]:
113
+ return _compact({
114
+ "numbers": _as_list(numbers),
115
+ "emails": _as_list(emails),
116
+ "upload_id": upload_id,
117
+ "checks": list(checks) if checks is not None else None,
118
+ "default_country": default_country,
119
+ "max_age": None if max_age is None else to_seconds(max_age),
120
+ "max_cost": None if max_cost is None else to_money(max_cost),
121
+ "webhook_endpoint_id": webhook_endpoint_id,
122
+ "metadata": metadata,
123
+ })
124
+
125
+
126
+ def _results_query(registered: Any, status: Optional[str], service: Optional[str], limit: Optional[int], after: Optional[str]) -> Dict[str, Any]:
127
+ return {"registered": _registered_param(registered), "status": status, "service": service, "limit": limit, "after": after}
128
+
129
+
130
+ def _resolve_key(api_key: Optional[str], sandbox: bool) -> Optional[str]:
131
+ if api_key:
132
+ return api_key
133
+ if sandbox:
134
+ return SANDBOX_PUBLIC_KEY
135
+ return os.environ.get("MOBILEVALIDATE_API_KEY") or None
136
+
137
+
138
+ def _resolve_base_url(base_url: Optional[str]) -> str:
139
+ return base_url or os.environ.get("MOBILEVALIDATE_BASE_URL") or DEFAULT_BASE_URL
140
+
141
+
142
+ class _Webhooks:
143
+ """``client.webhooks.verify(raw_body, headers, secret)`` — Standard Webhooks signature check."""
144
+
145
+ def verify(self, payload: Any, headers: Any, secret: str, *, tolerance: int = DEFAULT_TOLERANCE_SECONDS,
146
+ now: Optional[float] = None) -> Any:
147
+ return verify_webhook(payload, headers, secret, tolerance=tolerance, now=now)
148
+
149
+
150
+ # ---------------------------------------------------------------- sync client
151
+
152
+ class _SyncResource:
153
+ def __init__(self, client: "MobileValidate") -> None:
154
+ self._c = client
155
+ self._t = client._transport
156
+
157
+
158
+ class Lookups(_SyncResource):
159
+ def get(self, lookup_id: str, *, wait: Optional[int] = None, timeout: Optional[float] = None,
160
+ max_retries: Optional[int] = None) -> APIObject:
161
+ """Fetch a lookup; ``wait`` (0–30 s) long-polls until it completes."""
162
+ w = _clamp_wait(wait, 0)
163
+ return self._t.request("GET", f"/v1/lookups/{_p(lookup_id)}", query={"wait": w or None},
164
+ extra_timeout=w, timeout=timeout, max_retries=max_retries)
165
+
166
+
167
+ class Jobs(_SyncResource):
168
+ def create(self, *, numbers: Numbers = None, emails: Numbers = None, upload_id: Optional[str] = None,
169
+ checks: Optional[Sequence[str]] = None, default_country: Optional[str] = None,
170
+ max_age: Optional[DurationInput] = None, max_cost: Optional[MoneyInput] = None,
171
+ webhook_endpoint_id: Optional[str] = None, metadata: Optional[Dict[str, str]] = None,
172
+ idempotency_key: Optional[str] = None, timeout: Optional[float] = None,
173
+ max_retries: Optional[int] = None) -> APIObject:
174
+ """Create a bulk job (numbers and/or e-mails, ≤ 50,000 together)."""
175
+ body = _job_body(numbers, emails, upload_id, checks, default_country, max_age, max_cost, webhook_endpoint_id, metadata)
176
+ return self._t.request("POST", "/v1/jobs", body=body, idempotency_key=idempotency_key,
177
+ timeout=timeout, max_retries=max_retries)
178
+
179
+ def estimate(self, *, numbers: Numbers = None, emails: Numbers = None, upload_id: Optional[str] = None,
180
+ checks: Optional[Sequence[str]] = None, default_country: Optional[str] = None,
181
+ max_age: Optional[DurationInput] = None, max_cost: Optional[MoneyInput] = None,
182
+ idempotency_key: Optional[str] = None, timeout: Optional[float] = None,
183
+ max_retries: Optional[int] = None) -> APIObject:
184
+ """Free pre-flight: counts and maximum cost, no charge."""
185
+ body = _job_body(numbers, emails, upload_id, checks, default_country, max_age, max_cost, None, None)
186
+ return self._t.request("POST", "/v1/jobs/estimate", body=body, idempotency_key=idempotency_key,
187
+ timeout=timeout, max_retries=max_retries)
188
+
189
+ def get(self, job_id: str, *, wait: Optional[int] = None, timeout: Optional[float] = None,
190
+ max_retries: Optional[int] = None) -> APIObject:
191
+ w = _clamp_wait(wait, 0)
192
+ return self._t.request("GET", f"/v1/jobs/{_p(job_id)}", query={"wait": w or None}, extra_timeout=w,
193
+ timeout=timeout, max_retries=max_retries)
194
+
195
+ def wait(self, job_id: str, *, wait_timeout: float = 300.0, timeout: Optional[float] = None) -> APIObject:
196
+ """Long-poll until the job is completed, failed or cancelled, or ``wait_timeout`` seconds pass.
197
+
198
+ Returns the last job state (check ``job["status"]``)."""
199
+ deadline = time.monotonic() + wait_timeout
200
+ job = self.get(job_id, timeout=timeout)
201
+ while job.get("status") not in TERMINAL_JOB_STATUSES:
202
+ remaining = int(deadline - time.monotonic())
203
+ if remaining < 1:
204
+ break
205
+ job = self.get(job_id, wait=min(MAX_SERVER_WAIT_S, remaining), timeout=timeout)
206
+ if job.get("status") not in TERMINAL_JOB_STATUSES:
207
+ self._c._sleep(min(1.0, max(0.0, deadline - time.monotonic())))
208
+ return job
209
+
210
+ def results_page(self, job_id: str, *, registered: Any = None, status: Optional[str] = None,
211
+ service: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None,
212
+ timeout: Optional[float] = None) -> APIObject:
213
+ """One page of results (``{"data": [...], "has_more": bool, "next_cursor": str | None}``)."""
214
+ return self._t.request("GET", f"/v1/jobs/{_p(job_id)}/results",
215
+ query=_results_query(registered, status, service, limit, after), timeout=timeout)
216
+
217
+ def results(self, job_id: str, *, registered: Any = None, status: Optional[str] = None,
218
+ service: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None,
219
+ timeout: Optional[float] = None) -> Iterator[Dict[str, Any]]:
220
+ """Iterate over every result row, following cursors automatically."""
221
+ cursor = after
222
+ while True:
223
+ page = self.results_page(job_id, registered=registered, status=status, service=service, limit=limit,
224
+ after=cursor, timeout=timeout)
225
+ for item in page.get("data") or []:
226
+ yield item
227
+ cursor = page.get("next_cursor")
228
+ if not page.get("has_more") or not cursor:
229
+ return
230
+
231
+ def download(self, job_id: str, *, format: DownloadFormat = "csv", timeout: Optional[float] = None) -> str:
232
+ """The whole result file as text: CSV (default, header first) or NDJSON (one JSON object per line).
233
+
234
+ For large jobs prefer :meth:`download_to`, which streams to disk."""
235
+ with self._t.stream(f"/v1/jobs/{_p(job_id)}/download", query={"format": _fmt(format)},
236
+ accept=_ACCEPT[format], timeout=timeout) as r:
237
+ r.read()
238
+ return r.text
239
+
240
+ def download_to(self, job_id: str, path: Union[str, "os.PathLike[str]"], *, format: DownloadFormat = "csv",
241
+ timeout: Optional[float] = None) -> int:
242
+ """Stream the result file to ``path`` (overwritten). Returns the number of bytes written."""
243
+ written = 0
244
+ with self._t.stream(f"/v1/jobs/{_p(job_id)}/download", query={"format": _fmt(format)},
245
+ accept=_ACCEPT[format], timeout=timeout) as r, open(path, "wb") as f:
246
+ for chunk in r.iter_bytes():
247
+ written += f.write(chunk)
248
+ return written
249
+
250
+ def cancel(self, job_id: str, *, timeout: Optional[float] = None) -> APIObject:
251
+ """Cancel a running job (unsubmitted items are released) or purge a finished job's data."""
252
+ return self._t.request("DELETE", f"/v1/jobs/{_p(job_id)}", timeout=timeout)
253
+
254
+
255
+ class Account(_SyncResource):
256
+ def get(self, *, timeout: Optional[float] = None) -> APIObject:
257
+ return self._t.request("GET", "/v1/account", timeout=timeout)
258
+
259
+
260
+ class Limits(_SyncResource):
261
+ def get(self, *, timeout: Optional[float] = None) -> APIObject:
262
+ return self._t.request("GET", "/v1/limits", timeout=timeout)
263
+
264
+
265
+ class Usage(_SyncResource):
266
+ def get(self, *, from_date: str, to_date: str, group_by: Optional[str] = None,
267
+ timeout: Optional[float] = None) -> APIObject:
268
+ """Usage between two dates (YYYY-MM-DD); ``group_by`` = ``"day"`` or ``"service"``."""
269
+ return self._t.request("GET", "/v1/usage", query={"from": from_date, "to": to_date, "group_by": group_by},
270
+ timeout=timeout)
271
+
272
+
273
+ class WebhookEndpoints(_SyncResource):
274
+ def create(self, *, url: str, events: Sequence[str], idempotency_key: Optional[str] = None,
275
+ timeout: Optional[float] = None) -> APIObject:
276
+ """Register an https endpoint (inactive until the ownership challenge succeeds). The secret is shown once."""
277
+ return self._t.request("POST", "/v1/webhook_endpoints", body={"url": url, "events": list(events)},
278
+ idempotency_key=idempotency_key, timeout=timeout)
279
+
280
+ def list(self, *, timeout: Optional[float] = None) -> APIObject:
281
+ return self._t.request("GET", "/v1/webhook_endpoints", timeout=timeout)
282
+
283
+ def delete(self, endpoint_id: str, *, timeout: Optional[float] = None) -> APIObject:
284
+ return self._t.request("DELETE", f"/v1/webhook_endpoints/{_p(endpoint_id)}", timeout=timeout)
285
+
286
+ def test(self, endpoint_id: str, *, timeout: Optional[float] = None) -> APIObject:
287
+ """Queue a test event to the endpoint."""
288
+ return self._t.request("POST", f"/v1/webhook_endpoints/{_p(endpoint_id)}/test", body={}, timeout=timeout)
289
+
290
+
291
+ class MobileValidate:
292
+ """Synchronous MobileValidate client.
293
+
294
+ >>> mv = MobileValidate(sandbox=True) # public sandbox key: documented test values only
295
+ >>> lookup = mv.lookup("+447700900001", checks=["whatsapp"])
296
+ >>> lookup["results"][0]["checks"]["whatsapp.registered"]["registered"]
297
+ True
298
+ """
299
+
300
+ def __init__(
301
+ self,
302
+ api_key: Optional[str] = None,
303
+ *,
304
+ sandbox: bool = False,
305
+ base_url: Optional[str] = None,
306
+ timeout: float = 30.0,
307
+ max_retries: int = 2,
308
+ wait_timeout: float = 60.0,
309
+ http_client: Optional[httpx.Client] = None,
310
+ _sleep: Any = None,
311
+ _random: Any = None,
312
+ ) -> None:
313
+ self.wait_timeout = wait_timeout
314
+ self._sleep = _sleep or time.sleep
315
+ self._transport = SyncTransport(
316
+ api_key=_resolve_key(api_key, sandbox), base_url=_resolve_base_url(base_url), timeout=timeout,
317
+ max_retries=max_retries, http_client=http_client, sleep=self._sleep, rnd=_random or random.random,
318
+ )
319
+ self.lookups = Lookups(self)
320
+ self.jobs = Jobs(self)
321
+ self.account = Account(self)
322
+ self.limits = Limits(self)
323
+ self.usage = Usage(self)
324
+ self.webhook_endpoints = WebhookEndpoints(self)
325
+ self.webhooks = _Webhooks()
326
+
327
+ @property
328
+ def base_url(self) -> str:
329
+ return self._transport.base_url
330
+
331
+ def close(self) -> None:
332
+ self._transport.close()
333
+
334
+ def __enter__(self) -> "MobileValidate":
335
+ return self
336
+
337
+ def __exit__(self, *exc: Any) -> None:
338
+ self.close()
339
+
340
+ def lookup(
341
+ self,
342
+ numbers: Numbers = None,
343
+ *,
344
+ emails: Numbers = None,
345
+ checks: Optional[Sequence[str]] = None,
346
+ default_country: Optional[str] = None,
347
+ max_age: Optional[DurationInput] = None,
348
+ wait: Optional[int] = None,
349
+ wait_timeout: Optional[float] = None,
350
+ max_cost: Optional[MoneyInput] = None,
351
+ metadata: Optional[Dict[str, str]] = None,
352
+ webhook_endpoint_id: Optional[str] = None,
353
+ idempotency_key: Optional[str] = None,
354
+ timeout: Optional[float] = None,
355
+ max_retries: Optional[int] = None,
356
+ ) -> APIObject:
357
+ """Check 1–100 numbers and/or e-mail addresses in real time.
358
+
359
+ Waits for slow answers (long-polling) until the lookup completes or ``wait_timeout`` (default 60 s) runs out;
360
+ then the lookup is returned as-is with ``status == "pending"``. ``wait=0`` returns immediately.
361
+ """
362
+ w = _clamp_wait(wait, 10)
363
+ body = _lookup_body(_as_list(numbers), _as_list(emails), w, locals())
364
+ budget = self.wait_timeout if wait_timeout is None else wait_timeout
365
+ deadline = time.monotonic() + budget
366
+ result = self._transport.request("POST", "/v1/lookup", body=body, idempotency_key=idempotency_key,
367
+ extra_timeout=w, timeout=timeout, max_retries=max_retries)
368
+ while result.get("status") == "pending" and w > 0:
369
+ remaining = int(deadline - time.monotonic())
370
+ if remaining < 1:
371
+ break
372
+ poll_wait = min(MAX_SERVER_WAIT_S, remaining)
373
+ try:
374
+ nxt = self._transport.request("GET", f"/v1/lookups/{_p(result['id'])}", query={"wait": poll_wait},
375
+ extra_timeout=poll_wait, timeout=timeout, max_retries=max_retries)
376
+ except MobileValidateError as err:
377
+ if err.retryable:
378
+ break # keep the last good (pending) lookup
379
+ raise
380
+ prev, result = result, nxt
381
+ if result.get("status") == "pending": # a server that ignores ?wait: back off using its hint
382
+ hint = ((result.get("next") or {}).get("poll_after_ms") or (prev.get("next") or {}).get("poll_after_ms") or 1000)
383
+ pause = min(hint / 1000.0, max(0.0, deadline - time.monotonic()))
384
+ if pause > 0:
385
+ self._sleep(pause)
386
+ return result
387
+
388
+ def services(self, *, timeout: Optional[float] = None) -> APIObject:
389
+ """Service catalog for this key (``GET /v1/services``): codes, real time / bulk, prices, attributes."""
390
+ return self._transport.request("GET", "/v1/services", timeout=timeout)
391
+
392
+
393
+ # ---------------------------------------------------------------- async client
394
+
395
+ class _AsyncResource:
396
+ def __init__(self, client: "AsyncMobileValidate") -> None:
397
+ self._c = client
398
+ self._t = client._transport
399
+
400
+
401
+ class AsyncLookups(_AsyncResource):
402
+ async def get(self, lookup_id: str, *, wait: Optional[int] = None, timeout: Optional[float] = None,
403
+ max_retries: Optional[int] = None) -> APIObject:
404
+ w = _clamp_wait(wait, 0)
405
+ return await self._t.request("GET", f"/v1/lookups/{_p(lookup_id)}", query={"wait": w or None},
406
+ extra_timeout=w, timeout=timeout, max_retries=max_retries)
407
+
408
+
409
+ class AsyncJobs(_AsyncResource):
410
+ async def create(self, *, numbers: Numbers = None, emails: Numbers = None, upload_id: Optional[str] = None,
411
+ checks: Optional[Sequence[str]] = None, default_country: Optional[str] = None,
412
+ max_age: Optional[DurationInput] = None, max_cost: Optional[MoneyInput] = None,
413
+ webhook_endpoint_id: Optional[str] = None, metadata: Optional[Dict[str, str]] = None,
414
+ idempotency_key: Optional[str] = None, timeout: Optional[float] = None,
415
+ max_retries: Optional[int] = None) -> APIObject:
416
+ body = _job_body(numbers, emails, upload_id, checks, default_country, max_age, max_cost, webhook_endpoint_id, metadata)
417
+ return await self._t.request("POST", "/v1/jobs", body=body, idempotency_key=idempotency_key,
418
+ timeout=timeout, max_retries=max_retries)
419
+
420
+ async def estimate(self, *, numbers: Numbers = None, emails: Numbers = None, upload_id: Optional[str] = None,
421
+ checks: Optional[Sequence[str]] = None, default_country: Optional[str] = None,
422
+ max_age: Optional[DurationInput] = None, max_cost: Optional[MoneyInput] = None,
423
+ idempotency_key: Optional[str] = None, timeout: Optional[float] = None,
424
+ max_retries: Optional[int] = None) -> APIObject:
425
+ body = _job_body(numbers, emails, upload_id, checks, default_country, max_age, max_cost, None, None)
426
+ return await self._t.request("POST", "/v1/jobs/estimate", body=body, idempotency_key=idempotency_key,
427
+ timeout=timeout, max_retries=max_retries)
428
+
429
+ async def get(self, job_id: str, *, wait: Optional[int] = None, timeout: Optional[float] = None,
430
+ max_retries: Optional[int] = None) -> APIObject:
431
+ w = _clamp_wait(wait, 0)
432
+ return await self._t.request("GET", f"/v1/jobs/{_p(job_id)}", query={"wait": w or None}, extra_timeout=w,
433
+ timeout=timeout, max_retries=max_retries)
434
+
435
+ async def wait(self, job_id: str, *, wait_timeout: float = 300.0, timeout: Optional[float] = None) -> APIObject:
436
+ deadline = time.monotonic() + wait_timeout
437
+ job = await self.get(job_id, timeout=timeout)
438
+ while job.get("status") not in TERMINAL_JOB_STATUSES:
439
+ remaining = int(deadline - time.monotonic())
440
+ if remaining < 1:
441
+ break
442
+ job = await self.get(job_id, wait=min(MAX_SERVER_WAIT_S, remaining), timeout=timeout)
443
+ if job.get("status") not in TERMINAL_JOB_STATUSES:
444
+ await self._c._sleep(min(1.0, max(0.0, deadline - time.monotonic())))
445
+ return job
446
+
447
+ async def results_page(self, job_id: str, *, registered: Any = None, status: Optional[str] = None,
448
+ service: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None,
449
+ timeout: Optional[float] = None) -> APIObject:
450
+ return await self._t.request("GET", f"/v1/jobs/{_p(job_id)}/results",
451
+ query=_results_query(registered, status, service, limit, after), timeout=timeout)
452
+
453
+ async def results(self, job_id: str, *, registered: Any = None, status: Optional[str] = None,
454
+ service: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None,
455
+ timeout: Optional[float] = None) -> AsyncIterator[Dict[str, Any]]:
456
+ """``async for item in mv.jobs.results(job_id): ...`` — follows cursors automatically."""
457
+ cursor = after
458
+ while True:
459
+ page = await self.results_page(job_id, registered=registered, status=status, service=service,
460
+ limit=limit, after=cursor, timeout=timeout)
461
+ for item in page.get("data") or []:
462
+ yield item
463
+ cursor = page.get("next_cursor")
464
+ if not page.get("has_more") or not cursor:
465
+ return
466
+
467
+ async def download(self, job_id: str, *, format: DownloadFormat = "csv", timeout: Optional[float] = None) -> str:
468
+ """The whole result file as text (CSV or NDJSON); see :meth:`MobileValidate.jobs.download`."""
469
+ async with self._t.stream(f"/v1/jobs/{_p(job_id)}/download", query={"format": _fmt(format)},
470
+ accept=_ACCEPT[format], timeout=timeout) as r:
471
+ await r.aread()
472
+ return r.text
473
+
474
+ async def download_to(self, job_id: str, path: Union[str, "os.PathLike[str]"], *,
475
+ format: DownloadFormat = "csv", timeout: Optional[float] = None) -> int:
476
+ """Stream the result file to ``path`` (overwritten). Returns the number of bytes written."""
477
+ written = 0
478
+ async with self._t.stream(f"/v1/jobs/{_p(job_id)}/download", query={"format": _fmt(format)},
479
+ accept=_ACCEPT[format], timeout=timeout) as r:
480
+ with open(path, "wb") as f:
481
+ async for chunk in r.aiter_bytes():
482
+ written += f.write(chunk)
483
+ return written
484
+
485
+ async def cancel(self, job_id: str, *, timeout: Optional[float] = None) -> APIObject:
486
+ return await self._t.request("DELETE", f"/v1/jobs/{_p(job_id)}", timeout=timeout)
487
+
488
+
489
+ class AsyncAccount(_AsyncResource):
490
+ async def get(self, *, timeout: Optional[float] = None) -> APIObject:
491
+ return await self._t.request("GET", "/v1/account", timeout=timeout)
492
+
493
+
494
+ class AsyncLimits(_AsyncResource):
495
+ async def get(self, *, timeout: Optional[float] = None) -> APIObject:
496
+ return await self._t.request("GET", "/v1/limits", timeout=timeout)
497
+
498
+
499
+ class AsyncUsage(_AsyncResource):
500
+ async def get(self, *, from_date: str, to_date: str, group_by: Optional[str] = None,
501
+ timeout: Optional[float] = None) -> APIObject:
502
+ return await self._t.request("GET", "/v1/usage", query={"from": from_date, "to": to_date, "group_by": group_by},
503
+ timeout=timeout)
504
+
505
+
506
+ class AsyncWebhookEndpoints(_AsyncResource):
507
+ async def create(self, *, url: str, events: Sequence[str], idempotency_key: Optional[str] = None,
508
+ timeout: Optional[float] = None) -> APIObject:
509
+ return await self._t.request("POST", "/v1/webhook_endpoints", body={"url": url, "events": list(events)},
510
+ idempotency_key=idempotency_key, timeout=timeout)
511
+
512
+ async def list(self, *, timeout: Optional[float] = None) -> APIObject:
513
+ return await self._t.request("GET", "/v1/webhook_endpoints", timeout=timeout)
514
+
515
+ async def delete(self, endpoint_id: str, *, timeout: Optional[float] = None) -> APIObject:
516
+ return await self._t.request("DELETE", f"/v1/webhook_endpoints/{_p(endpoint_id)}", timeout=timeout)
517
+
518
+ async def test(self, endpoint_id: str, *, timeout: Optional[float] = None) -> APIObject:
519
+ return await self._t.request("POST", f"/v1/webhook_endpoints/{_p(endpoint_id)}/test", body={}, timeout=timeout)
520
+
521
+
522
+ class AsyncMobileValidate:
523
+ """Asyncio MobileValidate client (same methods as :class:`MobileValidate`, awaitable)."""
524
+
525
+ def __init__(
526
+ self,
527
+ api_key: Optional[str] = None,
528
+ *,
529
+ sandbox: bool = False,
530
+ base_url: Optional[str] = None,
531
+ timeout: float = 30.0,
532
+ max_retries: int = 2,
533
+ wait_timeout: float = 60.0,
534
+ http_client: Optional[httpx.AsyncClient] = None,
535
+ _sleep: Any = None,
536
+ _random: Any = None,
537
+ ) -> None:
538
+ import asyncio
539
+
540
+ self.wait_timeout = wait_timeout
541
+ self._sleep = _sleep or asyncio.sleep
542
+ self._transport = AsyncTransport(
543
+ api_key=_resolve_key(api_key, sandbox), base_url=_resolve_base_url(base_url), timeout=timeout,
544
+ max_retries=max_retries, http_client=http_client, sleep=self._sleep, rnd=_random or random.random,
545
+ )
546
+ self.lookups = AsyncLookups(self)
547
+ self.jobs = AsyncJobs(self)
548
+ self.account = AsyncAccount(self)
549
+ self.limits = AsyncLimits(self)
550
+ self.usage = AsyncUsage(self)
551
+ self.webhook_endpoints = AsyncWebhookEndpoints(self)
552
+ self.webhooks = _Webhooks()
553
+
554
+ @property
555
+ def base_url(self) -> str:
556
+ return self._transport.base_url
557
+
558
+ async def close(self) -> None:
559
+ await self._transport.close()
560
+
561
+ async def __aenter__(self) -> "AsyncMobileValidate":
562
+ return self
563
+
564
+ async def __aexit__(self, *exc: Any) -> None:
565
+ await self.close()
566
+
567
+ async def lookup(
568
+ self,
569
+ numbers: Numbers = None,
570
+ *,
571
+ emails: Numbers = None,
572
+ checks: Optional[Sequence[str]] = None,
573
+ default_country: Optional[str] = None,
574
+ max_age: Optional[DurationInput] = None,
575
+ wait: Optional[int] = None,
576
+ wait_timeout: Optional[float] = None,
577
+ max_cost: Optional[MoneyInput] = None,
578
+ metadata: Optional[Dict[str, str]] = None,
579
+ webhook_endpoint_id: Optional[str] = None,
580
+ idempotency_key: Optional[str] = None,
581
+ timeout: Optional[float] = None,
582
+ max_retries: Optional[int] = None,
583
+ ) -> APIObject:
584
+ w = _clamp_wait(wait, 10)
585
+ body = _lookup_body(_as_list(numbers), _as_list(emails), w, locals())
586
+ budget = self.wait_timeout if wait_timeout is None else wait_timeout
587
+ deadline = time.monotonic() + budget
588
+ result = await self._transport.request("POST", "/v1/lookup", body=body, idempotency_key=idempotency_key,
589
+ extra_timeout=w, timeout=timeout, max_retries=max_retries)
590
+ while result.get("status") == "pending" and w > 0:
591
+ remaining = int(deadline - time.monotonic())
592
+ if remaining < 1:
593
+ break
594
+ poll_wait = min(MAX_SERVER_WAIT_S, remaining)
595
+ try:
596
+ nxt = await self._transport.request("GET", f"/v1/lookups/{_p(result['id'])}", query={"wait": poll_wait},
597
+ extra_timeout=poll_wait, timeout=timeout, max_retries=max_retries)
598
+ except MobileValidateError as err:
599
+ if err.retryable:
600
+ break
601
+ raise
602
+ prev, result = result, nxt
603
+ if result.get("status") == "pending":
604
+ hint = ((result.get("next") or {}).get("poll_after_ms") or (prev.get("next") or {}).get("poll_after_ms") or 1000)
605
+ pause = min(hint / 1000.0, max(0.0, deadline - time.monotonic()))
606
+ if pause > 0:
607
+ await self._sleep(pause)
608
+ return result
609
+
610
+ async def services(self, *, timeout: Optional[float] = None) -> APIObject:
611
+ return await self._transport.request("GET", "/v1/services", timeout=timeout)
612
+