emailsherlock-sdk 0.1.0__tar.gz
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.
- emailsherlock_sdk-0.1.0/.github/workflows/ci.yml +14 -0
- emailsherlock_sdk-0.1.0/.github/workflows/publish.yml +20 -0
- emailsherlock_sdk-0.1.0/.gitignore +7 -0
- emailsherlock_sdk-0.1.0/LICENSE +21 -0
- emailsherlock_sdk-0.1.0/PKG-INFO +118 -0
- emailsherlock_sdk-0.1.0/README.md +99 -0
- emailsherlock_sdk-0.1.0/pyproject.toml +29 -0
- emailsherlock_sdk-0.1.0/src/emailsherlock/__init__.py +28 -0
- emailsherlock_sdk-0.1.0/src/emailsherlock/_version.py +1 -0
- emailsherlock_sdk-0.1.0/src/emailsherlock/client.py +133 -0
- emailsherlock_sdk-0.1.0/src/emailsherlock/errors.py +125 -0
- emailsherlock_sdk-0.1.0/src/emailsherlock/models.py +66 -0
- emailsherlock_sdk-0.1.0/tests/test_client.py +125 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
strategy:
|
|
7
|
+
matrix:
|
|
8
|
+
python: ['3.8', '3.9', '3.11', '3.12']
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: actions/setup-python@v5
|
|
12
|
+
with: { python-version: '${{ matrix.python }}' }
|
|
13
|
+
- run: pip install pytest
|
|
14
|
+
- run: pytest -q
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ['v*']
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write # PyPI Trusted Publishing (OIDC)
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with: { python-version: '3.11' }
|
|
17
|
+
- run: pip install build pytest
|
|
18
|
+
- run: pytest -q
|
|
19
|
+
- run: python -m build
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EmailSherlock
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: emailsherlock-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the EmailSherlock email-verification API.
|
|
5
|
+
Project-URL: Homepage, https://emailsherlock.com
|
|
6
|
+
Project-URL: Documentation, https://emailsherlock.com/api/docs
|
|
7
|
+
Project-URL: Source, https://github.com/Emailsherlock1/python
|
|
8
|
+
Author: EmailSherlock
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: disposable,email,email-validation,email-verification,emailsherlock,mx,smtp
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Communications :: Email
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# emailsherlock-sdk
|
|
21
|
+
|
|
22
|
+
Official Python client for the [EmailSherlock](https://emailsherlock.com) email-verification API. Verify one address or a batch over HTTPS with an API key.
|
|
23
|
+
|
|
24
|
+
Zero third-party dependencies (uses only the standard library). Python 3.8+.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install emailsherlock-sdk
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The distribution is `emailsherlock-sdk`; the import is `emailsherlock`:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from emailsherlock import Emailsherlock
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import os
|
|
42
|
+
from emailsherlock import Emailsherlock
|
|
43
|
+
|
|
44
|
+
# reads the key from the environment, never hard-code it
|
|
45
|
+
es = Emailsherlock(api_key=os.environ["ES_KEY"])
|
|
46
|
+
|
|
47
|
+
result = es.verify.single(email="jane@acme.com")
|
|
48
|
+
|
|
49
|
+
print(result.result) # "valid"
|
|
50
|
+
print(result.score) # 0.95
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Called with no argument, `Emailsherlock()` reads `ES_KEY` (or
|
|
54
|
+
`EMAILSHERLOCK_API_KEY`) from the environment.
|
|
55
|
+
|
|
56
|
+
## Batch
|
|
57
|
+
|
|
58
|
+
Up to 100 addresses per call:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
batch = es.verify.batch(emails=["jane@acme.com", "sales@acme.com"])
|
|
62
|
+
|
|
63
|
+
for item in batch.results:
|
|
64
|
+
if isinstance(item, VerifyResult):
|
|
65
|
+
print(item.email, item.result)
|
|
66
|
+
else: # BatchItemError
|
|
67
|
+
print(item.email, "failed:", item.error)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## The result object
|
|
71
|
+
|
|
72
|
+
`VerifyResult` mirrors the API JSON:
|
|
73
|
+
|
|
74
|
+
| attribute | type | meaning |
|
|
75
|
+
|--------------|-------|-----------------------------------------------------------------|
|
|
76
|
+
| `email` | str | the address you sent |
|
|
77
|
+
| `result` | str | `valid` · `invalid` · `catch_all` · `disposable` · `role` · `unknown` |
|
|
78
|
+
| `mx` | bool | the domain has reachable MX records |
|
|
79
|
+
| `disposable` | bool | throwaway / temporary-mail provider |
|
|
80
|
+
| `role` | bool | role address such as `info@` or `sales@` |
|
|
81
|
+
| `catch_all` | bool | host accepts mail for any local part |
|
|
82
|
+
| `score` | float | 0–1 confidence, higher is safer to send to |
|
|
83
|
+
| `freshness` | str | `fresh` · `cached_recent` · `cached_stale_refreshed` |
|
|
84
|
+
|
|
85
|
+
## Credits and rate limits
|
|
86
|
+
|
|
87
|
+
After every call:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
es.credits_remaining # e.g. 41
|
|
91
|
+
es.rate_limit # {"limit": 60, "remaining": 59, "reset": 1700000000}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Errors
|
|
95
|
+
|
|
96
|
+
Every failure raises a subclass of `EmailsherlockError`:
|
|
97
|
+
|
|
98
|
+
| class | HTTP | when |
|
|
99
|
+
|-----------------------------|------|-----------------------------------------------|
|
|
100
|
+
| `AuthenticationError` | 401 | missing or invalid API key |
|
|
101
|
+
| `ForbiddenError` | 403 | key lacks the endpoint's scope (`required_scope`) |
|
|
102
|
+
| `InsufficientCreditsError` | 402 | not enough credits (`credits_required`, `credits_remaining`) |
|
|
103
|
+
| `RateLimitError` | 429 | rate limit hit (`retry_after`, `limit`, `remaining`, `reset`) |
|
|
104
|
+
| `ValidationError` | 400 / 422 | the request body was rejected |
|
|
105
|
+
| `ServiceUnavailableError` | 503 | verify engine unavailable (the credit is auto-refunded) |
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from emailsherlock import RateLimitError
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
es.verify.single(email="jane@acme.com")
|
|
112
|
+
except RateLimitError as err:
|
|
113
|
+
print(f"retry after {err.retry_after}s")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT. Full API reference: https://emailsherlock.com/api/docs
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# emailsherlock-sdk
|
|
2
|
+
|
|
3
|
+
Official Python client for the [EmailSherlock](https://emailsherlock.com) email-verification API. Verify one address or a batch over HTTPS with an API key.
|
|
4
|
+
|
|
5
|
+
Zero third-party dependencies (uses only the standard library). Python 3.8+.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install emailsherlock-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The distribution is `emailsherlock-sdk`; the import is `emailsherlock`:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from emailsherlock import Emailsherlock
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import os
|
|
23
|
+
from emailsherlock import Emailsherlock
|
|
24
|
+
|
|
25
|
+
# reads the key from the environment, never hard-code it
|
|
26
|
+
es = Emailsherlock(api_key=os.environ["ES_KEY"])
|
|
27
|
+
|
|
28
|
+
result = es.verify.single(email="jane@acme.com")
|
|
29
|
+
|
|
30
|
+
print(result.result) # "valid"
|
|
31
|
+
print(result.score) # 0.95
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Called with no argument, `Emailsherlock()` reads `ES_KEY` (or
|
|
35
|
+
`EMAILSHERLOCK_API_KEY`) from the environment.
|
|
36
|
+
|
|
37
|
+
## Batch
|
|
38
|
+
|
|
39
|
+
Up to 100 addresses per call:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
batch = es.verify.batch(emails=["jane@acme.com", "sales@acme.com"])
|
|
43
|
+
|
|
44
|
+
for item in batch.results:
|
|
45
|
+
if isinstance(item, VerifyResult):
|
|
46
|
+
print(item.email, item.result)
|
|
47
|
+
else: # BatchItemError
|
|
48
|
+
print(item.email, "failed:", item.error)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## The result object
|
|
52
|
+
|
|
53
|
+
`VerifyResult` mirrors the API JSON:
|
|
54
|
+
|
|
55
|
+
| attribute | type | meaning |
|
|
56
|
+
|--------------|-------|-----------------------------------------------------------------|
|
|
57
|
+
| `email` | str | the address you sent |
|
|
58
|
+
| `result` | str | `valid` · `invalid` · `catch_all` · `disposable` · `role` · `unknown` |
|
|
59
|
+
| `mx` | bool | the domain has reachable MX records |
|
|
60
|
+
| `disposable` | bool | throwaway / temporary-mail provider |
|
|
61
|
+
| `role` | bool | role address such as `info@` or `sales@` |
|
|
62
|
+
| `catch_all` | bool | host accepts mail for any local part |
|
|
63
|
+
| `score` | float | 0–1 confidence, higher is safer to send to |
|
|
64
|
+
| `freshness` | str | `fresh` · `cached_recent` · `cached_stale_refreshed` |
|
|
65
|
+
|
|
66
|
+
## Credits and rate limits
|
|
67
|
+
|
|
68
|
+
After every call:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
es.credits_remaining # e.g. 41
|
|
72
|
+
es.rate_limit # {"limit": 60, "remaining": 59, "reset": 1700000000}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Errors
|
|
76
|
+
|
|
77
|
+
Every failure raises a subclass of `EmailsherlockError`:
|
|
78
|
+
|
|
79
|
+
| class | HTTP | when |
|
|
80
|
+
|-----------------------------|------|-----------------------------------------------|
|
|
81
|
+
| `AuthenticationError` | 401 | missing or invalid API key |
|
|
82
|
+
| `ForbiddenError` | 403 | key lacks the endpoint's scope (`required_scope`) |
|
|
83
|
+
| `InsufficientCreditsError` | 402 | not enough credits (`credits_required`, `credits_remaining`) |
|
|
84
|
+
| `RateLimitError` | 429 | rate limit hit (`retry_after`, `limit`, `remaining`, `reset`) |
|
|
85
|
+
| `ValidationError` | 400 / 422 | the request body was rejected |
|
|
86
|
+
| `ServiceUnavailableError` | 503 | verify engine unavailable (the credit is auto-refunded) |
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from emailsherlock import RateLimitError
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
es.verify.single(email="jane@acme.com")
|
|
93
|
+
except RateLimitError as err:
|
|
94
|
+
print(f"retry after {err.retry_after}s")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT. Full API reference: https://emailsherlock.com/api/docs
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "emailsherlock-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python client for the EmailSherlock email-verification API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "EmailSherlock" }]
|
|
13
|
+
keywords = ["email", "email-verification", "email-validation", "emailsherlock", "mx", "smtp", "disposable"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Communications :: Email",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://emailsherlock.com"
|
|
25
|
+
Documentation = "https://emailsherlock.com/api/docs"
|
|
26
|
+
Source = "https://github.com/Emailsherlock1/python"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/emailsherlock"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Official Python client for the EmailSherlock email-verification API."""
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
from .client import Emailsherlock
|
|
4
|
+
from .errors import (
|
|
5
|
+
AuthenticationError,
|
|
6
|
+
EmailsherlockError,
|
|
7
|
+
ForbiddenError,
|
|
8
|
+
InsufficientCreditsError,
|
|
9
|
+
RateLimitError,
|
|
10
|
+
ServiceUnavailableError,
|
|
11
|
+
ValidationError,
|
|
12
|
+
)
|
|
13
|
+
from .models import BatchItemError, BatchResponse, VerifyResult
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"__version__",
|
|
17
|
+
"Emailsherlock",
|
|
18
|
+
"VerifyResult",
|
|
19
|
+
"BatchResponse",
|
|
20
|
+
"BatchItemError",
|
|
21
|
+
"EmailsherlockError",
|
|
22
|
+
"AuthenticationError",
|
|
23
|
+
"ForbiddenError",
|
|
24
|
+
"InsufficientCreditsError",
|
|
25
|
+
"RateLimitError",
|
|
26
|
+
"ValidationError",
|
|
27
|
+
"ServiceUnavailableError",
|
|
28
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""HTTP client for the EmailSherlock verify API. Zero third-party dependencies."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .errors import EmailsherlockError, error_from_response
|
|
12
|
+
from .models import BatchResponse, VerifyResult
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://api.emailsherlock.com"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _num(value: Optional[str]) -> Optional[float]:
|
|
18
|
+
if value is None:
|
|
19
|
+
return None
|
|
20
|
+
try:
|
|
21
|
+
return float(value)
|
|
22
|
+
except (TypeError, ValueError):
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _VerifyResource:
|
|
27
|
+
"""Verify-endpoint methods, reached as ``client.verify``."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, client: "Emailsherlock") -> None:
|
|
30
|
+
self._client = client
|
|
31
|
+
|
|
32
|
+
def single(self, email: str) -> VerifyResult:
|
|
33
|
+
"""Verify a single address."""
|
|
34
|
+
data = self._client._request("/v1/verify/single", {"email": email})
|
|
35
|
+
return VerifyResult.from_dict(data)
|
|
36
|
+
|
|
37
|
+
def batch(self, emails: List[str]) -> BatchResponse:
|
|
38
|
+
"""Verify up to 100 addresses in one call."""
|
|
39
|
+
data = self._client._request("/v1/verify/batch", {"emails": list(emails)})
|
|
40
|
+
return BatchResponse.from_dict(data)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Emailsherlock:
|
|
44
|
+
"""Client for the EmailSherlock verify API.
|
|
45
|
+
|
|
46
|
+
The API key is read from ``api_key`` or, if omitted, from the ``ES_KEY`` /
|
|
47
|
+
``EMAILSHERLOCK_API_KEY`` environment variables.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
api_key: Optional[str] = None,
|
|
53
|
+
*,
|
|
54
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
55
|
+
timeout: float = 30.0,
|
|
56
|
+
) -> None:
|
|
57
|
+
key = api_key or os.environ.get("ES_KEY") or os.environ.get("EMAILSHERLOCK_API_KEY")
|
|
58
|
+
if not key:
|
|
59
|
+
raise EmailsherlockError(
|
|
60
|
+
"No API key provided. Pass api_key= or set ES_KEY.",
|
|
61
|
+
code="config_error",
|
|
62
|
+
)
|
|
63
|
+
self._api_key = key
|
|
64
|
+
self._base_url = base_url.rstrip("/")
|
|
65
|
+
self._timeout = timeout
|
|
66
|
+
|
|
67
|
+
#: credits left after the most recent request (X-Credits-Remaining)
|
|
68
|
+
self.credits_remaining: Optional[float] = None
|
|
69
|
+
#: rate-limit window after the most recent request (X-RateLimit-*)
|
|
70
|
+
self.rate_limit: Dict[str, Optional[float]] = {
|
|
71
|
+
"limit": None,
|
|
72
|
+
"remaining": None,
|
|
73
|
+
"reset": None,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
self.verify = _VerifyResource(self)
|
|
77
|
+
|
|
78
|
+
def _request(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
79
|
+
url = self._base_url + path
|
|
80
|
+
body = json.dumps(payload).encode("utf-8")
|
|
81
|
+
request = urllib.request.Request(
|
|
82
|
+
url,
|
|
83
|
+
data=body,
|
|
84
|
+
method="POST",
|
|
85
|
+
headers={
|
|
86
|
+
"X-API-Key": self._api_key,
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
"Accept": "application/json",
|
|
89
|
+
"User-Agent": "emailsherlock-python/{}".format(__version__),
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
95
|
+
raw = response.read().decode("utf-8")
|
|
96
|
+
self._capture_meta(response.headers)
|
|
97
|
+
status = response.status
|
|
98
|
+
headers = response.headers
|
|
99
|
+
except urllib.error.HTTPError as exc:
|
|
100
|
+
raw = exc.read().decode("utf-8")
|
|
101
|
+
self._capture_meta(exc.headers)
|
|
102
|
+
parsed = self._parse(raw)
|
|
103
|
+
raise error_from_response(exc.code, parsed, exc.headers)
|
|
104
|
+
except urllib.error.URLError as exc:
|
|
105
|
+
raise EmailsherlockError(
|
|
106
|
+
"Request to {} failed: {}".format(path, exc.reason),
|
|
107
|
+
code="network_error",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
parsed = self._parse(raw)
|
|
111
|
+
if status >= 400:
|
|
112
|
+
raise error_from_response(status, parsed, headers)
|
|
113
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
114
|
+
|
|
115
|
+
def _capture_meta(self, headers: Any) -> None:
|
|
116
|
+
credits = headers.get("X-Credits-Remaining")
|
|
117
|
+
if credits is not None:
|
|
118
|
+
self.credits_remaining = _num(credits)
|
|
119
|
+
if headers.get("X-RateLimit-Limit") is not None:
|
|
120
|
+
self.rate_limit = {
|
|
121
|
+
"limit": _num(headers.get("X-RateLimit-Limit")),
|
|
122
|
+
"remaining": _num(headers.get("X-RateLimit-Remaining")),
|
|
123
|
+
"reset": _num(headers.get("X-RateLimit-Reset")),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _parse(raw: str) -> Optional[Dict[str, Any]]:
|
|
128
|
+
if not raw:
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
return json.loads(raw)
|
|
132
|
+
except json.JSONDecodeError:
|
|
133
|
+
return None
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Exception hierarchy. Every failure raises a subclass of EmailsherlockError."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _num(value: Optional[str]) -> Optional[float]:
|
|
8
|
+
if value is None:
|
|
9
|
+
return None
|
|
10
|
+
try:
|
|
11
|
+
return float(value)
|
|
12
|
+
except (TypeError, ValueError):
|
|
13
|
+
return None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EmailsherlockError(Exception):
|
|
17
|
+
"""Base class for every error this client raises."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
message: str,
|
|
22
|
+
status: Optional[int] = None,
|
|
23
|
+
code: Optional[str] = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.message = message
|
|
27
|
+
self.status = status
|
|
28
|
+
self.code = code
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthenticationError(EmailsherlockError):
|
|
32
|
+
"""401 - missing or invalid API key."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ForbiddenError(EmailsherlockError):
|
|
36
|
+
"""403 - the key lacks the scope this endpoint needs."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, *args: Any, required_scope: Optional[str] = None, **kw: Any) -> None:
|
|
39
|
+
super().__init__(*args, **kw)
|
|
40
|
+
self.required_scope = required_scope
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InsufficientCreditsError(EmailsherlockError):
|
|
44
|
+
"""402 - not enough credits on the wallet for this request."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*args: Any,
|
|
49
|
+
credits_required: Optional[float] = None,
|
|
50
|
+
credits_remaining: Optional[float] = None,
|
|
51
|
+
**kw: Any,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(*args, **kw)
|
|
54
|
+
self.credits_required = credits_required
|
|
55
|
+
self.credits_remaining = credits_remaining
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RateLimitError(EmailsherlockError):
|
|
59
|
+
"""429 - per-key sliding-window rate limit hit."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*args: Any,
|
|
64
|
+
retry_after: Optional[float] = None,
|
|
65
|
+
limit: Optional[float] = None,
|
|
66
|
+
remaining: Optional[float] = None,
|
|
67
|
+
reset: Optional[float] = None,
|
|
68
|
+
**kw: Any,
|
|
69
|
+
) -> None:
|
|
70
|
+
super().__init__(*args, **kw)
|
|
71
|
+
self.retry_after = retry_after
|
|
72
|
+
self.limit = limit
|
|
73
|
+
self.remaining = remaining
|
|
74
|
+
self.reset = reset
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ValidationError(EmailsherlockError):
|
|
78
|
+
"""400 / 422 - the request body was rejected."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ServiceUnavailableError(EmailsherlockError):
|
|
82
|
+
"""503 - the verify engine could not answer; the credit was auto-refunded."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def error_from_response(
|
|
86
|
+
status: int,
|
|
87
|
+
body: Optional[Dict[str, Any]],
|
|
88
|
+
headers: Any,
|
|
89
|
+
) -> EmailsherlockError:
|
|
90
|
+
envelope = (body or {}).get("error", {}) if isinstance(body, dict) else {}
|
|
91
|
+
code = envelope.get("code")
|
|
92
|
+
message = envelope.get("message") or "HTTP {}".format(status)
|
|
93
|
+
|
|
94
|
+
if status == 401:
|
|
95
|
+
return AuthenticationError(message, status=status, code=code)
|
|
96
|
+
if status == 403:
|
|
97
|
+
return ForbiddenError(
|
|
98
|
+
message,
|
|
99
|
+
status=status,
|
|
100
|
+
code=code,
|
|
101
|
+
required_scope=envelope.get("required_scope") or headers.get("X-Required-Scope"),
|
|
102
|
+
)
|
|
103
|
+
if status == 402:
|
|
104
|
+
return InsufficientCreditsError(
|
|
105
|
+
message,
|
|
106
|
+
status=status,
|
|
107
|
+
code=code,
|
|
108
|
+
credits_required=_num(headers.get("X-Credits-Required")),
|
|
109
|
+
credits_remaining=_num(headers.get("X-Credits-Remaining")),
|
|
110
|
+
)
|
|
111
|
+
if status == 429:
|
|
112
|
+
return RateLimitError(
|
|
113
|
+
message,
|
|
114
|
+
status=status,
|
|
115
|
+
code=code,
|
|
116
|
+
retry_after=_num(headers.get("Retry-After")),
|
|
117
|
+
limit=_num(headers.get("X-RateLimit-Limit")),
|
|
118
|
+
remaining=_num(headers.get("X-RateLimit-Remaining")),
|
|
119
|
+
reset=_num(headers.get("X-RateLimit-Reset")),
|
|
120
|
+
)
|
|
121
|
+
if status in (400, 422):
|
|
122
|
+
return ValidationError(message, status=status, code=code)
|
|
123
|
+
if status == 503:
|
|
124
|
+
return ServiceUnavailableError(message, status=status, code=code)
|
|
125
|
+
return EmailsherlockError(message, status=status, code=code)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Typed result objects returned by the client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Dict, List, Optional, Union
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class VerifyResult:
|
|
10
|
+
"""The result for one verified address. Mirrors the API JSON."""
|
|
11
|
+
|
|
12
|
+
email: str
|
|
13
|
+
result: str # valid | invalid | catch_all | disposable | role | unknown
|
|
14
|
+
mx: bool
|
|
15
|
+
disposable: bool
|
|
16
|
+
role: bool
|
|
17
|
+
catch_all: bool
|
|
18
|
+
score: float
|
|
19
|
+
freshness: str # fresh | cached_recent | cached_stale_refreshed
|
|
20
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def from_dict(cls, data: Dict[str, Any]) -> "VerifyResult":
|
|
24
|
+
return cls(
|
|
25
|
+
email=data.get("email", ""),
|
|
26
|
+
result=data.get("result", "unknown"),
|
|
27
|
+
mx=bool(data.get("mx", False)),
|
|
28
|
+
disposable=bool(data.get("disposable", False)),
|
|
29
|
+
role=bool(data.get("role", False)),
|
|
30
|
+
catch_all=bool(data.get("catch_all", False)),
|
|
31
|
+
score=float(data.get("score", 0.0)),
|
|
32
|
+
freshness=data.get("freshness", ""),
|
|
33
|
+
raw=data,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class BatchItemError:
|
|
39
|
+
"""A per-address failure inside a batch response."""
|
|
40
|
+
|
|
41
|
+
email: Optional[str]
|
|
42
|
+
error: str # invalid_email | insufficient_credits | verify_unavailable
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_dict(cls, data: Dict[str, Any]) -> "BatchItemError":
|
|
46
|
+
return cls(email=data.get("email"), error=data.get("error", "unknown"))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
BatchItem = Union[VerifyResult, BatchItemError]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class BatchResponse:
|
|
54
|
+
"""The response of a batch call: a list of results and/or per-item errors."""
|
|
55
|
+
|
|
56
|
+
results: List[BatchItem]
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, data: Dict[str, Any]) -> "BatchResponse":
|
|
60
|
+
items: List[BatchItem] = []
|
|
61
|
+
for item in data.get("results", []):
|
|
62
|
+
if isinstance(item, dict) and "error" in item:
|
|
63
|
+
items.append(BatchItemError.from_dict(item))
|
|
64
|
+
else:
|
|
65
|
+
items.append(VerifyResult.from_dict(item))
|
|
66
|
+
return cls(results=items)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Unit tests with a stubbed urlopen. No network."""
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import urllib.error
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
11
|
+
|
|
12
|
+
import emailsherlock
|
|
13
|
+
from emailsherlock import (
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
Emailsherlock,
|
|
16
|
+
InsufficientCreditsError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
VerifyResult,
|
|
19
|
+
BatchItemError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeHeaders(dict):
|
|
24
|
+
def get(self, key, default=None):
|
|
25
|
+
for k, v in self.items():
|
|
26
|
+
if k.lower() == key.lower():
|
|
27
|
+
return v
|
|
28
|
+
return default
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FakeResponse:
|
|
32
|
+
def __init__(self, status, body, headers):
|
|
33
|
+
self.status = status
|
|
34
|
+
self._body = json.dumps(body).encode()
|
|
35
|
+
self.headers = FakeHeaders(headers)
|
|
36
|
+
|
|
37
|
+
def read(self):
|
|
38
|
+
return self._body
|
|
39
|
+
|
|
40
|
+
def __enter__(self):
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def __exit__(self, *a):
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def patch(monkeypatch, status, body, headers=None):
|
|
48
|
+
headers = headers or {}
|
|
49
|
+
if status >= 400:
|
|
50
|
+
def raiser(req, timeout=None):
|
|
51
|
+
raise urllib.error.HTTPError(
|
|
52
|
+
req.full_url, status, "err", FakeHeaders(headers),
|
|
53
|
+
io.BytesIO(json.dumps(body).encode()),
|
|
54
|
+
)
|
|
55
|
+
monkeypatch.setattr(emailsherlock.client.urllib.request, "urlopen", raiser)
|
|
56
|
+
else:
|
|
57
|
+
monkeypatch.setattr(
|
|
58
|
+
emailsherlock.client.urllib.request,
|
|
59
|
+
"urlopen",
|
|
60
|
+
lambda req, timeout=None: FakeResponse(status, body, headers),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_single_ok(monkeypatch):
|
|
65
|
+
patch(
|
|
66
|
+
monkeypatch, 200,
|
|
67
|
+
{"email": "jane@acme.com", "result": "valid", "mx": True, "disposable": False,
|
|
68
|
+
"role": False, "catch_all": False, "score": 0.95, "freshness": "fresh"},
|
|
69
|
+
{"X-Credits-Remaining": "41", "X-RateLimit-Limit": "60", "X-RateLimit-Remaining": "59", "X-RateLimit-Reset": "1700000000"},
|
|
70
|
+
)
|
|
71
|
+
es = Emailsherlock("k")
|
|
72
|
+
result = es.verify.single("jane@acme.com")
|
|
73
|
+
assert isinstance(result, VerifyResult)
|
|
74
|
+
assert result.result == "valid"
|
|
75
|
+
assert result.score == 0.95
|
|
76
|
+
assert es.credits_remaining == 41
|
|
77
|
+
assert es.rate_limit["limit"] == 60
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_batch_mixed(monkeypatch):
|
|
81
|
+
patch(monkeypatch, 200, {"results": [
|
|
82
|
+
{"email": "jane@acme.com", "result": "valid", "mx": True, "disposable": False, "role": False, "catch_all": False, "score": 0.9, "freshness": "fresh"},
|
|
83
|
+
{"email": "nope@", "error": "invalid_email"},
|
|
84
|
+
]})
|
|
85
|
+
es = Emailsherlock("k")
|
|
86
|
+
batch = es.verify.batch(["jane@acme.com", "nope@"])
|
|
87
|
+
assert len(batch.results) == 2
|
|
88
|
+
assert isinstance(batch.results[0], VerifyResult)
|
|
89
|
+
assert isinstance(batch.results[1], BatchItemError)
|
|
90
|
+
assert batch.results[1].error == "invalid_email"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_401(monkeypatch):
|
|
94
|
+
patch(monkeypatch, 401, {"error": {"code": "unauthorized", "message": "Invalid API key."}})
|
|
95
|
+
es = Emailsherlock("bad")
|
|
96
|
+
with pytest.raises(AuthenticationError) as exc:
|
|
97
|
+
es.verify.single("a@b.com")
|
|
98
|
+
assert exc.value.status == 401
|
|
99
|
+
assert exc.value.code == "unauthorized"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_402(monkeypatch):
|
|
103
|
+
patch(monkeypatch, 402, {"error": {"code": "insufficient_credits", "message": "Not enough."}},
|
|
104
|
+
{"X-Credits-Required": "1", "X-Credits-Remaining": "0"})
|
|
105
|
+
es = Emailsherlock("k")
|
|
106
|
+
with pytest.raises(InsufficientCreditsError) as exc:
|
|
107
|
+
es.verify.single("a@b.com")
|
|
108
|
+
assert exc.value.credits_required == 1
|
|
109
|
+
assert exc.value.credits_remaining == 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_429(monkeypatch):
|
|
113
|
+
patch(monkeypatch, 429, {"error": {"code": "rate_limit_exceeded", "message": "Slow down."}},
|
|
114
|
+
{"Retry-After": "30"})
|
|
115
|
+
es = Emailsherlock("k")
|
|
116
|
+
with pytest.raises(RateLimitError) as exc:
|
|
117
|
+
es.verify.single("a@b.com")
|
|
118
|
+
assert exc.value.retry_after == 30
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_missing_key(monkeypatch):
|
|
122
|
+
monkeypatch.delenv("ES_KEY", raising=False)
|
|
123
|
+
monkeypatch.delenv("EMAILSHERLOCK_API_KEY", raising=False)
|
|
124
|
+
with pytest.raises(emailsherlock.EmailsherlockError):
|
|
125
|
+
Emailsherlock()
|