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.
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
@@ -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.
@@ -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)