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.
- mobilevalidate/__init__.py +65 -0
- mobilevalidate/_client.py +612 -0
- mobilevalidate/_constants.py +42 -0
- mobilevalidate/_errors.py +364 -0
- mobilevalidate/_http.py +285 -0
- mobilevalidate/py.typed +0 -0
- mobilevalidate/types.py +166 -0
- mobilevalidate/webhooks.py +103 -0
- mobilevalidate_sdk-1.0.0.dist-info/METADATA +339 -0
- mobilevalidate_sdk-1.0.0.dist-info/RECORD +12 -0
- mobilevalidate_sdk-1.0.0.dist-info/WHEEL +4 -0
- mobilevalidate_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
|