apiddress 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.
- apiddress-0.1.0/.gitignore +5 -0
- apiddress-0.1.0/LICENSE +21 -0
- apiddress-0.1.0/PKG-INFO +149 -0
- apiddress-0.1.0/README.md +130 -0
- apiddress-0.1.0/apiddress/__init__.py +30 -0
- apiddress-0.1.0/apiddress/client.py +228 -0
- apiddress-0.1.0/apiddress/errors.py +40 -0
- apiddress-0.1.0/apiddress/models.py +218 -0
- apiddress-0.1.0/apiddress/py.typed +0 -0
- apiddress-0.1.0/pyproject.toml +33 -0
- apiddress-0.1.0/tests/test_integration.py +130 -0
apiddress-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 APIddress
|
|
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.
|
apiddress-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apiddress
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the APIddress email validation API
|
|
5
|
+
Project-URL: Homepage, https://api.apiddress.com
|
|
6
|
+
Author: APIddress
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: apiddress,email,email-verification,validation
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Communications :: Email
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# apiddress
|
|
21
|
+
|
|
22
|
+
Official Python SDK for the [APIddress](https://api.apiddress.com) email validation API.
|
|
23
|
+
|
|
24
|
+
- Zero runtime dependencies (stdlib `urllib` only)
|
|
25
|
+
- Python 3.9+, fully type-hinted, frozen dataclass responses
|
|
26
|
+
- Automatic retry with backoff on `429` and `5xx` (batch creation is never retried)
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install apiddress
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from apiddress import Client
|
|
38
|
+
|
|
39
|
+
client = Client("YOUR_API_KEY")
|
|
40
|
+
|
|
41
|
+
result = client.validate_email("ada@stripe.com")
|
|
42
|
+
print(result.status) # "valid"
|
|
43
|
+
print(result.score) # 0.98
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
client = Client(
|
|
50
|
+
"YOUR_API_KEY",
|
|
51
|
+
base_url="https://api.apiddress.com", # default
|
|
52
|
+
timeout=10.0, # per-request timeout (seconds), default 10
|
|
53
|
+
max_retries=2, # retries on 429/5xx, default 2
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
### Validate one email
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
result = client.validate_email(
|
|
63
|
+
"john@company.com",
|
|
64
|
+
check_smtp=False, # default
|
|
65
|
+
allow_role_based=True, # server default
|
|
66
|
+
)
|
|
67
|
+
# result.status: "valid" | "invalid" | "risky" | "disposable" | "unknown"
|
|
68
|
+
# result.suggestion: "john@gmail.com" for typo-like addresses, else None
|
|
69
|
+
# result.checks: ValidationChecks(syntax, domain_exists, mx, smtp, disposable, ...)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A malformed value (e.g. `"not-an-email"`) is a verdict, not an error: you get
|
|
73
|
+
`status == "invalid"` with `reason == "invalid_syntax"`.
|
|
74
|
+
|
|
75
|
+
### Validate up to 100 emails synchronously
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
response = client.validate_emails(["a@example.com", "b@example.com"])
|
|
79
|
+
print(response.count, [r.status for r in response.results])
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Batch jobs (up to 5000 emails)
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
batch = client.create_batch(
|
|
86
|
+
emails,
|
|
87
|
+
callback_url="https://yourapp.com/webhooks/apiddress", # optional
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
done = client.wait_for_batch(
|
|
91
|
+
batch.batch_id,
|
|
92
|
+
poll_interval=1.0, # default
|
|
93
|
+
timeout=60.0, # default
|
|
94
|
+
)
|
|
95
|
+
print(done.status, len(done.results))
|
|
96
|
+
|
|
97
|
+
# Or poll yourself:
|
|
98
|
+
status = client.get_batch(batch.batch_id)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`wait_for_batch` returns the terminal state (`"completed"` or `"failed"`) —
|
|
102
|
+
check `status` before using `results`.
|
|
103
|
+
|
|
104
|
+
### Account
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
profile = client.me() # plan, limits, usage
|
|
108
|
+
usage = client.usage() # current month
|
|
109
|
+
may = client.usage("2026-05") # specific month
|
|
110
|
+
health = client.health() # no auth required
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Error handling
|
|
114
|
+
|
|
115
|
+
Every failed request raises an `APIddressError`:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from apiddress import APIddressError, Client
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
client.validate_email("john@company.com")
|
|
122
|
+
except APIddressError as err:
|
|
123
|
+
print(err.status, err.code, err.message, err.details)
|
|
124
|
+
# 429 quota_exceeded "Monthly request limit exceeded." {"requests_used": ..., "requests_limit": ...}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
| `status` | `code` |
|
|
128
|
+
| -------- | ------------------ |
|
|
129
|
+
| 400 | `invalid_request` |
|
|
130
|
+
| 401 | `unauthorized` |
|
|
131
|
+
| 404 | `not_found` |
|
|
132
|
+
| 429 | `quota_exceeded` |
|
|
133
|
+
| 500 | `internal_error` |
|
|
134
|
+
| 0 | `timeout` (request or `wait_for_batch` timeout) |
|
|
135
|
+
|
|
136
|
+
## Development
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
python3 -m venv .venv
|
|
140
|
+
.venv/bin/pip install -e . pytest
|
|
141
|
+
|
|
142
|
+
# Integration tests need a live backend:
|
|
143
|
+
APIDDRESS_BASE_URL=http://localhost:3000 APIDDRESS_API_KEY=test_key_local_dev \
|
|
144
|
+
.venv/bin/pytest
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
MIT
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# apiddress
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [APIddress](https://api.apiddress.com) email validation API.
|
|
4
|
+
|
|
5
|
+
- Zero runtime dependencies (stdlib `urllib` only)
|
|
6
|
+
- Python 3.9+, fully type-hinted, frozen dataclass responses
|
|
7
|
+
- Automatic retry with backoff on `429` and `5xx` (batch creation is never retried)
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install apiddress
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quickstart
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from apiddress import Client
|
|
19
|
+
|
|
20
|
+
client = Client("YOUR_API_KEY")
|
|
21
|
+
|
|
22
|
+
result = client.validate_email("ada@stripe.com")
|
|
23
|
+
print(result.status) # "valid"
|
|
24
|
+
print(result.score) # 0.98
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
client = Client(
|
|
31
|
+
"YOUR_API_KEY",
|
|
32
|
+
base_url="https://api.apiddress.com", # default
|
|
33
|
+
timeout=10.0, # per-request timeout (seconds), default 10
|
|
34
|
+
max_retries=2, # retries on 429/5xx, default 2
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
### Validate one email
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
result = client.validate_email(
|
|
44
|
+
"john@company.com",
|
|
45
|
+
check_smtp=False, # default
|
|
46
|
+
allow_role_based=True, # server default
|
|
47
|
+
)
|
|
48
|
+
# result.status: "valid" | "invalid" | "risky" | "disposable" | "unknown"
|
|
49
|
+
# result.suggestion: "john@gmail.com" for typo-like addresses, else None
|
|
50
|
+
# result.checks: ValidationChecks(syntax, domain_exists, mx, smtp, disposable, ...)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A malformed value (e.g. `"not-an-email"`) is a verdict, not an error: you get
|
|
54
|
+
`status == "invalid"` with `reason == "invalid_syntax"`.
|
|
55
|
+
|
|
56
|
+
### Validate up to 100 emails synchronously
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
response = client.validate_emails(["a@example.com", "b@example.com"])
|
|
60
|
+
print(response.count, [r.status for r in response.results])
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Batch jobs (up to 5000 emails)
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
batch = client.create_batch(
|
|
67
|
+
emails,
|
|
68
|
+
callback_url="https://yourapp.com/webhooks/apiddress", # optional
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
done = client.wait_for_batch(
|
|
72
|
+
batch.batch_id,
|
|
73
|
+
poll_interval=1.0, # default
|
|
74
|
+
timeout=60.0, # default
|
|
75
|
+
)
|
|
76
|
+
print(done.status, len(done.results))
|
|
77
|
+
|
|
78
|
+
# Or poll yourself:
|
|
79
|
+
status = client.get_batch(batch.batch_id)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`wait_for_batch` returns the terminal state (`"completed"` or `"failed"`) —
|
|
83
|
+
check `status` before using `results`.
|
|
84
|
+
|
|
85
|
+
### Account
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
profile = client.me() # plan, limits, usage
|
|
89
|
+
usage = client.usage() # current month
|
|
90
|
+
may = client.usage("2026-05") # specific month
|
|
91
|
+
health = client.health() # no auth required
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Error handling
|
|
95
|
+
|
|
96
|
+
Every failed request raises an `APIddressError`:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from apiddress import APIddressError, Client
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
client.validate_email("john@company.com")
|
|
103
|
+
except APIddressError as err:
|
|
104
|
+
print(err.status, err.code, err.message, err.details)
|
|
105
|
+
# 429 quota_exceeded "Monthly request limit exceeded." {"requests_used": ..., "requests_limit": ...}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
| `status` | `code` |
|
|
109
|
+
| -------- | ------------------ |
|
|
110
|
+
| 400 | `invalid_request` |
|
|
111
|
+
| 401 | `unauthorized` |
|
|
112
|
+
| 404 | `not_found` |
|
|
113
|
+
| 429 | `quota_exceeded` |
|
|
114
|
+
| 500 | `internal_error` |
|
|
115
|
+
| 0 | `timeout` (request or `wait_for_batch` timeout) |
|
|
116
|
+
|
|
117
|
+
## Development
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
python3 -m venv .venv
|
|
121
|
+
.venv/bin/pip install -e . pytest
|
|
122
|
+
|
|
123
|
+
# Integration tests need a live backend:
|
|
124
|
+
APIDDRESS_BASE_URL=http://localhost:3000 APIDDRESS_API_KEY=test_key_local_dev \
|
|
125
|
+
.venv/bin/pytest
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Official Python SDK for the APIddress email validation API."""
|
|
2
|
+
|
|
3
|
+
from .client import Client
|
|
4
|
+
from .errors import APIddressError
|
|
5
|
+
from .models import (
|
|
6
|
+
ApiKeyProfileResponse,
|
|
7
|
+
BatchAcceptedResponse,
|
|
8
|
+
BatchStatusResponse,
|
|
9
|
+
BulkValidateResponse,
|
|
10
|
+
HealthResponse,
|
|
11
|
+
UsageResponse,
|
|
12
|
+
ValidateEmailResponse,
|
|
13
|
+
ValidationChecks,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Client",
|
|
20
|
+
"APIddressError",
|
|
21
|
+
"ApiKeyProfileResponse",
|
|
22
|
+
"BatchAcceptedResponse",
|
|
23
|
+
"BatchStatusResponse",
|
|
24
|
+
"BulkValidateResponse",
|
|
25
|
+
"HealthResponse",
|
|
26
|
+
"UsageResponse",
|
|
27
|
+
"ValidateEmailResponse",
|
|
28
|
+
"ValidationChecks",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Official APIddress client (stdlib-only, Python 3.9+)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import random
|
|
5
|
+
import socket
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from .errors import APIddressError
|
|
13
|
+
from .models import (
|
|
14
|
+
ApiKeyProfileResponse,
|
|
15
|
+
BatchAcceptedResponse,
|
|
16
|
+
BatchStatusResponse,
|
|
17
|
+
BulkValidateResponse,
|
|
18
|
+
HealthResponse,
|
|
19
|
+
UsageResponse,
|
|
20
|
+
ValidateEmailResponse,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = ["Client"]
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.apiddress.com"
|
|
26
|
+
DEFAULT_TIMEOUT = 10.0
|
|
27
|
+
DEFAULT_MAX_RETRIES = 2
|
|
28
|
+
|
|
29
|
+
_TERMINAL_BATCH_STATUSES = ("completed", "failed")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Client:
|
|
33
|
+
"""APIddress email validation client.
|
|
34
|
+
|
|
35
|
+
>>> from apiddress import Client
|
|
36
|
+
>>> client = Client("YOUR_API_KEY")
|
|
37
|
+
>>> result = client.validate_email("ada@stripe.com")
|
|
38
|
+
>>> result.status
|
|
39
|
+
'valid'
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
api_key: str,
|
|
45
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
46
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
47
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""
|
|
50
|
+
:param api_key: Your APIddress API key (sent as ``x-api-key``).
|
|
51
|
+
:param base_url: API origin. Defaults to production.
|
|
52
|
+
:param timeout: Per-request timeout in seconds. Default 10.
|
|
53
|
+
:param max_retries: Retries on 429/5xx. Default 2.
|
|
54
|
+
Batch creation is never retried.
|
|
55
|
+
"""
|
|
56
|
+
if not api_key or not isinstance(api_key, str):
|
|
57
|
+
raise TypeError("apiddress.Client: an API key string is required")
|
|
58
|
+
self._api_key = api_key
|
|
59
|
+
self._base_url = base_url.rstrip("/")
|
|
60
|
+
self._timeout = timeout
|
|
61
|
+
self._max_retries = max_retries
|
|
62
|
+
|
|
63
|
+
# -- Validation ----------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def validate_email(
|
|
66
|
+
self,
|
|
67
|
+
email: str,
|
|
68
|
+
check_smtp: bool = False,
|
|
69
|
+
allow_role_based: Optional[bool] = None,
|
|
70
|
+
) -> ValidateEmailResponse:
|
|
71
|
+
"""Validate a single email address. Costs 1 request."""
|
|
72
|
+
body: Dict[str, Any] = {"email": email, "check_smtp": check_smtp}
|
|
73
|
+
if allow_role_based is not None:
|
|
74
|
+
body["allow_role_based"] = allow_role_based
|
|
75
|
+
data = self._request("POST", "/api/v1/validate-email", body=body)
|
|
76
|
+
return ValidateEmailResponse.from_dict(data)
|
|
77
|
+
|
|
78
|
+
def validate_emails(
|
|
79
|
+
self,
|
|
80
|
+
emails: List[str],
|
|
81
|
+
check_smtp: bool = False,
|
|
82
|
+
) -> BulkValidateResponse:
|
|
83
|
+
"""Validate 1-100 email addresses synchronously. Costs one request per email."""
|
|
84
|
+
data = self._request(
|
|
85
|
+
"POST",
|
|
86
|
+
"/api/v1/validate-emails",
|
|
87
|
+
body={"emails": emails, "check_smtp": check_smtp},
|
|
88
|
+
)
|
|
89
|
+
return BulkValidateResponse.from_dict(data)
|
|
90
|
+
|
|
91
|
+
def create_batch(
|
|
92
|
+
self,
|
|
93
|
+
emails: List[str],
|
|
94
|
+
callback_url: Optional[str] = None,
|
|
95
|
+
check_smtp: bool = False,
|
|
96
|
+
) -> BatchAcceptedResponse:
|
|
97
|
+
"""Create an asynchronous batch job for 1-5000 email addresses.
|
|
98
|
+
|
|
99
|
+
Never retried automatically (to avoid duplicate jobs).
|
|
100
|
+
"""
|
|
101
|
+
body: Dict[str, Any] = {"emails": emails, "check_smtp": check_smtp}
|
|
102
|
+
if callback_url is not None:
|
|
103
|
+
body["callback_url"] = callback_url
|
|
104
|
+
data = self._request("POST", "/api/v1/batches", body=body, retryable=False)
|
|
105
|
+
return BatchAcceptedResponse.from_dict(data)
|
|
106
|
+
|
|
107
|
+
def get_batch(self, batch_id: str) -> BatchStatusResponse:
|
|
108
|
+
"""Get the current state (and results, when completed) of a batch job."""
|
|
109
|
+
data = self._request(
|
|
110
|
+
"GET", "/api/v1/batches/" + urllib.parse.quote(batch_id, safe="")
|
|
111
|
+
)
|
|
112
|
+
return BatchStatusResponse.from_dict(data)
|
|
113
|
+
|
|
114
|
+
def wait_for_batch(
|
|
115
|
+
self,
|
|
116
|
+
batch_id: str,
|
|
117
|
+
poll_interval: float = 1.0,
|
|
118
|
+
timeout: float = 60.0,
|
|
119
|
+
) -> BatchStatusResponse:
|
|
120
|
+
"""Poll a batch job until it reaches a terminal state.
|
|
121
|
+
|
|
122
|
+
Returns the final state (``status`` is ``"completed"`` or ``"failed"``).
|
|
123
|
+
Raises :class:`APIddressError` with ``code="timeout"`` if the job is
|
|
124
|
+
still running after ``timeout`` seconds.
|
|
125
|
+
"""
|
|
126
|
+
deadline = time.monotonic() + timeout
|
|
127
|
+
while True:
|
|
128
|
+
batch = self.get_batch(batch_id)
|
|
129
|
+
if batch.status in _TERMINAL_BATCH_STATUSES:
|
|
130
|
+
return batch
|
|
131
|
+
if time.monotonic() + poll_interval > deadline:
|
|
132
|
+
raise APIddressError(
|
|
133
|
+
0,
|
|
134
|
+
"timeout",
|
|
135
|
+
f"Batch {batch_id} did not complete within {timeout}s "
|
|
136
|
+
f"(last status: {batch.status})",
|
|
137
|
+
)
|
|
138
|
+
time.sleep(poll_interval)
|
|
139
|
+
|
|
140
|
+
# -- Account -------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def me(self) -> ApiKeyProfileResponse:
|
|
143
|
+
"""Get the profile (plan, limits, usage) of the authenticated API key."""
|
|
144
|
+
return ApiKeyProfileResponse.from_dict(self._request("GET", "/api/v1/me"))
|
|
145
|
+
|
|
146
|
+
def usage(self, month: Optional[str] = None) -> UsageResponse:
|
|
147
|
+
"""Get usage for a month ("YYYY-MM"). Defaults to the current month."""
|
|
148
|
+
query = {"month": month} if month is not None else None
|
|
149
|
+
return UsageResponse.from_dict(
|
|
150
|
+
self._request("GET", "/api/v1/usage", query=query)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def health(self) -> HealthResponse:
|
|
154
|
+
"""Service health check. Does not require authentication."""
|
|
155
|
+
return HealthResponse.from_dict(
|
|
156
|
+
self._request("GET", "/api/v1/health", auth=False)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# -- Transport -----------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def _request(
|
|
162
|
+
self,
|
|
163
|
+
method: str,
|
|
164
|
+
path: str,
|
|
165
|
+
body: Optional[Dict[str, Any]] = None,
|
|
166
|
+
query: Optional[Dict[str, str]] = None,
|
|
167
|
+
auth: bool = True,
|
|
168
|
+
retryable: bool = True,
|
|
169
|
+
) -> Dict[str, Any]:
|
|
170
|
+
url = self._base_url + path
|
|
171
|
+
if query:
|
|
172
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
173
|
+
|
|
174
|
+
headers = {"accept": "application/json"}
|
|
175
|
+
if auth:
|
|
176
|
+
headers["x-api-key"] = self._api_key
|
|
177
|
+
|
|
178
|
+
data: Optional[bytes] = None
|
|
179
|
+
if body is not None:
|
|
180
|
+
data = json.dumps(body).encode("utf-8")
|
|
181
|
+
headers["content-type"] = "application/json"
|
|
182
|
+
|
|
183
|
+
max_attempts = self._max_retries + 1 if retryable else 1
|
|
184
|
+
attempt = 0
|
|
185
|
+
while True:
|
|
186
|
+
request = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
187
|
+
try:
|
|
188
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
189
|
+
return json.loads(response.read().decode("utf-8"))
|
|
190
|
+
except urllib.error.HTTPError as http_error:
|
|
191
|
+
error = _parse_error_envelope(http_error)
|
|
192
|
+
should_retry = attempt + 1 < max_attempts and (
|
|
193
|
+
http_error.code == 429 or http_error.code >= 500
|
|
194
|
+
)
|
|
195
|
+
if not should_retry:
|
|
196
|
+
raise error from None
|
|
197
|
+
except urllib.error.URLError as url_error:
|
|
198
|
+
if isinstance(url_error.reason, (socket.timeout, TimeoutError)):
|
|
199
|
+
raise APIddressError(
|
|
200
|
+
0, "timeout", f"Request to {path} timed out after {self._timeout}s"
|
|
201
|
+
) from None
|
|
202
|
+
raise
|
|
203
|
+
except (socket.timeout, TimeoutError):
|
|
204
|
+
raise APIddressError(
|
|
205
|
+
0, "timeout", f"Request to {path} timed out after {self._timeout}s"
|
|
206
|
+
) from None
|
|
207
|
+
time.sleep(_backoff_seconds(attempt))
|
|
208
|
+
attempt += 1
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _backoff_seconds(attempt: int) -> float:
|
|
212
|
+
"""Exponential backoff with a little jitter: ~0.25s, ~0.5s, ~1s, capped at 4s."""
|
|
213
|
+
return min(0.25 * (2 ** attempt), 4.0) + random.random() * 0.1
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _parse_error_envelope(http_error: urllib.error.HTTPError) -> APIddressError:
|
|
217
|
+
code = "unknown_error"
|
|
218
|
+
message = f"HTTP {http_error.code}"
|
|
219
|
+
details: Optional[Dict[str, Any]] = None
|
|
220
|
+
try:
|
|
221
|
+
payload = json.loads(http_error.read().decode("utf-8"))
|
|
222
|
+
envelope = payload.get("error") or {}
|
|
223
|
+
code = envelope.get("code", code)
|
|
224
|
+
message = envelope.get("message", message)
|
|
225
|
+
details = envelope.get("details")
|
|
226
|
+
except (ValueError, UnicodeDecodeError):
|
|
227
|
+
pass # Non-JSON error body; keep defaults.
|
|
228
|
+
return APIddressError(http_error.code, code, message, details)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Error type for the APIddress SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
__all__ = ["APIddressError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class APIddressError(Exception):
|
|
9
|
+
"""Raised for any failed APIddress request.
|
|
10
|
+
|
|
11
|
+
For HTTP errors, ``status`` is the response status code and ``code`` /
|
|
12
|
+
``message`` / ``details`` come from the API error envelope
|
|
13
|
+
``{"error": {"code", "message", "details"}}``.
|
|
14
|
+
|
|
15
|
+
For client-side failures (request timeout, batch polling timeout),
|
|
16
|
+
``status`` is 0 and ``code`` is ``"timeout"``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
status: int,
|
|
22
|
+
code: str,
|
|
23
|
+
message: str,
|
|
24
|
+
details: Optional[Dict[str, Any]] = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
#: HTTP status code, or 0 for client-side failures.
|
|
28
|
+
self.status = status
|
|
29
|
+
#: Machine-readable error code, e.g. "unauthorized", "quota_exceeded".
|
|
30
|
+
self.code = code
|
|
31
|
+
#: Human-readable message.
|
|
32
|
+
self.message = message
|
|
33
|
+
#: Extra context from the API, or None.
|
|
34
|
+
self.details = details
|
|
35
|
+
|
|
36
|
+
def __repr__(self) -> str:
|
|
37
|
+
return (
|
|
38
|
+
f"APIddressError(status={self.status!r}, code={self.code!r}, "
|
|
39
|
+
f"message={self.message!r}, details={self.details!r})"
|
|
40
|
+
)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Response models for the APIddress API.
|
|
2
|
+
|
|
3
|
+
Hand-mapped 1:1 from the OpenAPI 3.1 contract (``backend/openapi.yaml``).
|
|
4
|
+
Attribute names match the wire format exactly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ValidationChecks",
|
|
12
|
+
"ValidateEmailResponse",
|
|
13
|
+
"BulkValidateResponse",
|
|
14
|
+
"BatchAcceptedResponse",
|
|
15
|
+
"BatchStatusResponse",
|
|
16
|
+
"ApiKeyProfileResponse",
|
|
17
|
+
"UsageResponse",
|
|
18
|
+
"HealthResponse",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ValidationChecks:
|
|
24
|
+
"""Individual checks performed on an email address."""
|
|
25
|
+
|
|
26
|
+
syntax: bool
|
|
27
|
+
domain_exists: bool
|
|
28
|
+
mx: bool
|
|
29
|
+
#: None when the SMTP probe was skipped or inconclusive.
|
|
30
|
+
smtp: Optional[bool]
|
|
31
|
+
disposable: bool
|
|
32
|
+
role_based: bool
|
|
33
|
+
free_provider: bool
|
|
34
|
+
#: None when not determined.
|
|
35
|
+
catch_all: Optional[bool]
|
|
36
|
+
typo: bool
|
|
37
|
+
#: "low" | "medium" | "high" | None
|
|
38
|
+
spam_trap_risk: Optional[str]
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ValidationChecks":
|
|
42
|
+
return cls(
|
|
43
|
+
syntax=data["syntax"],
|
|
44
|
+
domain_exists=data["domain_exists"],
|
|
45
|
+
mx=data["mx"],
|
|
46
|
+
smtp=data["smtp"],
|
|
47
|
+
disposable=data["disposable"],
|
|
48
|
+
role_based=data["role_based"],
|
|
49
|
+
free_provider=data["free_provider"],
|
|
50
|
+
catch_all=data["catch_all"],
|
|
51
|
+
typo=data["typo"],
|
|
52
|
+
spam_trap_risk=data["spam_trap_risk"],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ValidateEmailResponse:
|
|
58
|
+
"""Result of validating a single email address."""
|
|
59
|
+
|
|
60
|
+
email: str
|
|
61
|
+
normalized_email: str
|
|
62
|
+
#: "valid" | "invalid" | "risky" | "disposable" | "unknown"
|
|
63
|
+
status: str
|
|
64
|
+
valid: bool
|
|
65
|
+
#: Confidence score between 0 and 1.
|
|
66
|
+
score: float
|
|
67
|
+
#: Machine-readable reason, e.g. "accepted_email", "invalid_syntax".
|
|
68
|
+
reason: str
|
|
69
|
+
#: Suggested correction for typo-like addresses, or None.
|
|
70
|
+
suggestion: Optional[str]
|
|
71
|
+
checks: ValidationChecks
|
|
72
|
+
provider: Optional[str]
|
|
73
|
+
created_at: str
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ValidateEmailResponse":
|
|
77
|
+
return cls(
|
|
78
|
+
email=data["email"],
|
|
79
|
+
normalized_email=data["normalized_email"],
|
|
80
|
+
status=data["status"],
|
|
81
|
+
valid=data["valid"],
|
|
82
|
+
score=data["score"],
|
|
83
|
+
reason=data["reason"],
|
|
84
|
+
suggestion=data["suggestion"],
|
|
85
|
+
checks=ValidationChecks.from_dict(data["checks"]),
|
|
86
|
+
provider=data["provider"],
|
|
87
|
+
created_at=data["created_at"],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class BulkValidateResponse:
|
|
93
|
+
"""Result of a synchronous bulk validation."""
|
|
94
|
+
|
|
95
|
+
count: int
|
|
96
|
+
results: List[ValidateEmailResponse]
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_dict(cls, data: Dict[str, Any]) -> "BulkValidateResponse":
|
|
100
|
+
return cls(
|
|
101
|
+
count=data["count"],
|
|
102
|
+
results=[ValidateEmailResponse.from_dict(item) for item in data["results"]],
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class BatchAcceptedResponse:
|
|
108
|
+
"""Returned when an asynchronous batch job is accepted (HTTP 202)."""
|
|
109
|
+
|
|
110
|
+
batch_id: str
|
|
111
|
+
#: Always "pending" on acceptance.
|
|
112
|
+
status: str
|
|
113
|
+
submitted_count: int
|
|
114
|
+
created_at: str
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_dict(cls, data: Dict[str, Any]) -> "BatchAcceptedResponse":
|
|
118
|
+
return cls(
|
|
119
|
+
batch_id=data["batch_id"],
|
|
120
|
+
status=data["status"],
|
|
121
|
+
submitted_count=data["submitted_count"],
|
|
122
|
+
created_at=data["created_at"],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class BatchStatusResponse:
|
|
128
|
+
"""Current state of an asynchronous batch job."""
|
|
129
|
+
|
|
130
|
+
batch_id: str
|
|
131
|
+
#: "pending" | "processing" | "completed" | "failed"
|
|
132
|
+
status: str
|
|
133
|
+
submitted_count: int
|
|
134
|
+
processed_count: int
|
|
135
|
+
created_at: str
|
|
136
|
+
completed_at: Optional[str]
|
|
137
|
+
#: Per-email results once the batch is completed, otherwise None.
|
|
138
|
+
results: Optional[List[ValidateEmailResponse]]
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def from_dict(cls, data: Dict[str, Any]) -> "BatchStatusResponse":
|
|
142
|
+
raw_results = data["results"]
|
|
143
|
+
return cls(
|
|
144
|
+
batch_id=data["batch_id"],
|
|
145
|
+
status=data["status"],
|
|
146
|
+
submitted_count=data["submitted_count"],
|
|
147
|
+
processed_count=data["processed_count"],
|
|
148
|
+
created_at=data["created_at"],
|
|
149
|
+
completed_at=data["completed_at"],
|
|
150
|
+
results=(
|
|
151
|
+
None
|
|
152
|
+
if raw_results is None
|
|
153
|
+
else [ValidateEmailResponse.from_dict(item) for item in raw_results]
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class ApiKeyProfileResponse:
|
|
160
|
+
"""Profile of the authenticated API key."""
|
|
161
|
+
|
|
162
|
+
id: str
|
|
163
|
+
email: str
|
|
164
|
+
#: "free" | "paid" | "enterprise"
|
|
165
|
+
plan: str
|
|
166
|
+
is_active: bool
|
|
167
|
+
requests_limit: int
|
|
168
|
+
requests_used: int
|
|
169
|
+
created_at: str
|
|
170
|
+
|
|
171
|
+
@classmethod
|
|
172
|
+
def from_dict(cls, data: Dict[str, Any]) -> "ApiKeyProfileResponse":
|
|
173
|
+
return cls(
|
|
174
|
+
id=data["id"],
|
|
175
|
+
email=data["email"],
|
|
176
|
+
plan=data["plan"],
|
|
177
|
+
is_active=data["is_active"],
|
|
178
|
+
requests_limit=data["requests_limit"],
|
|
179
|
+
requests_used=data["requests_used"],
|
|
180
|
+
created_at=data["created_at"],
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class UsageResponse:
|
|
186
|
+
"""Monthly usage for the authenticated API key."""
|
|
187
|
+
|
|
188
|
+
#: Month in YYYY-MM format.
|
|
189
|
+
month: str
|
|
190
|
+
requests_used: int
|
|
191
|
+
requests_limit: int
|
|
192
|
+
remaining: int
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def from_dict(cls, data: Dict[str, Any]) -> "UsageResponse":
|
|
196
|
+
return cls(
|
|
197
|
+
month=data["month"],
|
|
198
|
+
requests_used=data["requests_used"],
|
|
199
|
+
requests_limit=data["requests_limit"],
|
|
200
|
+
remaining=data["remaining"],
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True)
|
|
205
|
+
class HealthResponse:
|
|
206
|
+
"""Service health payload."""
|
|
207
|
+
|
|
208
|
+
status: str
|
|
209
|
+
timestamp: str
|
|
210
|
+
version: str
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def from_dict(cls, data: Dict[str, Any]) -> "HealthResponse":
|
|
214
|
+
return cls(
|
|
215
|
+
status=data["status"],
|
|
216
|
+
timestamp=data["timestamp"],
|
|
217
|
+
version=data["version"],
|
|
218
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "apiddress"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the APIddress email validation API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "APIddress" }]
|
|
14
|
+
keywords = ["email", "validation", "email-verification", "apiddress"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Communications :: Email",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://api.apiddress.com"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["apiddress"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Integration tests against a live APIddress backend.
|
|
2
|
+
|
|
3
|
+
APIDDRESS_BASE_URL=http://localhost:3000 APIDDRESS_API_KEY=test_key_local_dev pytest
|
|
4
|
+
|
|
5
|
+
Quota cost of this suite: 9 validations
|
|
6
|
+
(4 single + 3 bulk + 2 batch; me/usage/health/errors are free).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from apiddress import APIddressError, Client
|
|
15
|
+
|
|
16
|
+
BASE_URL = os.environ.get("APIDDRESS_BASE_URL", "http://localhost:3000")
|
|
17
|
+
API_KEY = os.environ.get("APIDDRESS_API_KEY", "test_key_local_dev")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture(scope="module")
|
|
21
|
+
def client() -> Client:
|
|
22
|
+
return Client(API_KEY, base_url=BASE_URL)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestHealth:
|
|
26
|
+
def test_health_without_auth(self, client: Client) -> None:
|
|
27
|
+
health = client.health()
|
|
28
|
+
assert health.status == "ok"
|
|
29
|
+
assert isinstance(health.version, str)
|
|
30
|
+
assert isinstance(health.timestamp, str)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestValidateEmail:
|
|
34
|
+
def test_valid_corporate_address(self, client: Client) -> None:
|
|
35
|
+
result = client.validate_email("ada@stripe.com")
|
|
36
|
+
assert result.status == "valid"
|
|
37
|
+
assert result.valid is True
|
|
38
|
+
assert result.email == "ada@stripe.com"
|
|
39
|
+
assert result.normalized_email == "ada@stripe.com"
|
|
40
|
+
assert result.score > 0.5
|
|
41
|
+
assert result.checks.syntax is True
|
|
42
|
+
assert result.checks.mx is True
|
|
43
|
+
assert result.checks.disposable is False
|
|
44
|
+
# check_smtp defaults to False, so the probe must be skipped.
|
|
45
|
+
assert result.checks.smtp is None
|
|
46
|
+
|
|
47
|
+
def test_disposable_address(self, client: Client) -> None:
|
|
48
|
+
result = client.validate_email("temp@mailinator.com")
|
|
49
|
+
assert result.status == "disposable"
|
|
50
|
+
assert result.valid is False
|
|
51
|
+
assert result.checks.disposable is True
|
|
52
|
+
|
|
53
|
+
def test_typo_suggestion(self, client: Client) -> None:
|
|
54
|
+
result = client.validate_email("jane@gmial.com")
|
|
55
|
+
assert result.checks.typo is True
|
|
56
|
+
assert result.suggestion == "jane@gmail.com"
|
|
57
|
+
|
|
58
|
+
def test_malformed_is_a_verdict_not_an_error(self, client: Client) -> None:
|
|
59
|
+
result = client.validate_email("not-an-email")
|
|
60
|
+
assert result.status == "invalid"
|
|
61
|
+
assert result.valid is False
|
|
62
|
+
assert result.reason == "invalid_syntax"
|
|
63
|
+
assert result.checks.syntax is False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestValidateEmails:
|
|
67
|
+
def test_bulk_of_three(self, client: Client) -> None:
|
|
68
|
+
response = client.validate_emails(
|
|
69
|
+
["ada@stripe.com", "temp@mailinator.com", "nope"]
|
|
70
|
+
)
|
|
71
|
+
assert response.count == 3
|
|
72
|
+
assert len(response.results) == 3
|
|
73
|
+
assert [r.status for r in response.results] == [
|
|
74
|
+
"valid",
|
|
75
|
+
"disposable",
|
|
76
|
+
"invalid",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestBatches:
|
|
81
|
+
def test_batch_lifecycle(self, client: Client) -> None:
|
|
82
|
+
accepted = client.create_batch(["ada@stripe.com", "temp@mailinator.com"])
|
|
83
|
+
assert accepted.status == "pending"
|
|
84
|
+
assert accepted.submitted_count == 2
|
|
85
|
+
assert len(accepted.batch_id) >= 8
|
|
86
|
+
|
|
87
|
+
done = client.wait_for_batch(accepted.batch_id, poll_interval=0.5, timeout=20.0)
|
|
88
|
+
assert done.status == "completed"
|
|
89
|
+
assert done.batch_id == accepted.batch_id
|
|
90
|
+
assert done.processed_count == 2
|
|
91
|
+
assert done.completed_at is not None
|
|
92
|
+
assert done.results is not None and len(done.results) == 2
|
|
93
|
+
assert done.results[0].status == "valid"
|
|
94
|
+
|
|
95
|
+
def test_unknown_batch_is_404(self, client: Client) -> None:
|
|
96
|
+
with pytest.raises(APIddressError) as excinfo:
|
|
97
|
+
client.get_batch("does-not-exist-123")
|
|
98
|
+
assert excinfo.value.status == 404
|
|
99
|
+
assert excinfo.value.code == "not_found"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestAccount:
|
|
103
|
+
def test_me(self, client: Client) -> None:
|
|
104
|
+
profile = client.me()
|
|
105
|
+
assert profile.is_active is True
|
|
106
|
+
assert profile.plan in ("free", "paid", "enterprise")
|
|
107
|
+
assert profile.requests_limit > 0
|
|
108
|
+
assert profile.requests_used >= 0
|
|
109
|
+
|
|
110
|
+
def test_usage_current_month(self, client: Client) -> None:
|
|
111
|
+
usage = client.usage()
|
|
112
|
+
assert re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", usage.month)
|
|
113
|
+
assert usage.remaining == usage.requests_limit - usage.requests_used
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestErrors:
|
|
117
|
+
def test_bad_api_key_is_401(self) -> None:
|
|
118
|
+
bad = Client("definitely_not_a_key", base_url=BASE_URL)
|
|
119
|
+
with pytest.raises(APIddressError) as excinfo:
|
|
120
|
+
bad.me()
|
|
121
|
+
assert excinfo.value.status == 401
|
|
122
|
+
assert excinfo.value.code == "unauthorized"
|
|
123
|
+
assert len(excinfo.value.message) > 0
|
|
124
|
+
|
|
125
|
+
def test_400_error_envelope_mapping(self, client: Client) -> None:
|
|
126
|
+
with pytest.raises(APIddressError) as excinfo:
|
|
127
|
+
client.validate_emails([])
|
|
128
|
+
assert excinfo.value.status == 400
|
|
129
|
+
assert excinfo.value.code == "invalid_request"
|
|
130
|
+
assert isinstance(excinfo.value.message, str)
|