checkharbor 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,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: checkharbor
3
+ Version: 0.1.0
4
+ Summary: Official Checkharbor Python SDK — email/phone/IP validation
5
+ License: MIT
6
+ Project-URL: Homepage, https://checkharbor.com
7
+ Project-URL: Documentation, https://docs.checkharbor.com
8
+ Project-URL: Repository, https://github.com/checkharbor/checkharbor-python
9
+ Keywords: checkharbor,email-validation,phone-validation,ip-intelligence
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: requests>=2.28
13
+
14
+ # checkharbor
15
+
16
+ Official Checkharbor Python SDK.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install checkharbor
22
+ ```
23
+
24
+ ## Quick start
25
+
26
+ ```python
27
+ from checkharbor import Checkharbor
28
+
29
+ client = Checkharbor(api_key="chk_live_...")
30
+
31
+ # Validate email
32
+ email = client.validate_email("john@example.com")
33
+ print(email["score"], email["deliverability"])
34
+
35
+ # Validate phone
36
+ phone = client.validate_phone("+905321234567", country_hint="TR")
37
+
38
+ # IP intelligence
39
+ ip = client.ip_intel("84.17.45.10")
40
+
41
+ # Unified fraud check
42
+ fraud = client.verify(email="john@example.com", ip="84.17.45.10")
43
+ print(fraud["fraud_score"], fraud["risk"])
44
+
45
+ # Batch
46
+ job = client.batch.create(
47
+ type="email",
48
+ rows=["a@example.com", "b@example.com"],
49
+ webhook_url="https://myapp.com/webhook",
50
+ )
51
+ done = client.batch.wait_until_done(job["id"])
52
+ csv = client.batch.download_result(done)
53
+ ```
54
+
55
+ ## Error handling
56
+
57
+ ```python
58
+ from checkharbor import CheckharborError
59
+
60
+ try:
61
+ result = client.validate_email("bad@test.com")
62
+ except CheckharborError as e:
63
+ print(e.code, e.status, str(e))
64
+ # e.g. "insufficient_credits" 402 "Not enough credits"
65
+ ```
66
+
67
+ ## Development
68
+
69
+ ```bash
70
+ python3 -m venv .venv && source .venv/bin/activate
71
+ pip install -e ".[dev]"
72
+ pytest
73
+ ```
@@ -0,0 +1,60 @@
1
+ # checkharbor
2
+
3
+ Official Checkharbor Python SDK.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install checkharbor
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ from checkharbor import Checkharbor
15
+
16
+ client = Checkharbor(api_key="chk_live_...")
17
+
18
+ # Validate email
19
+ email = client.validate_email("john@example.com")
20
+ print(email["score"], email["deliverability"])
21
+
22
+ # Validate phone
23
+ phone = client.validate_phone("+905321234567", country_hint="TR")
24
+
25
+ # IP intelligence
26
+ ip = client.ip_intel("84.17.45.10")
27
+
28
+ # Unified fraud check
29
+ fraud = client.verify(email="john@example.com", ip="84.17.45.10")
30
+ print(fraud["fraud_score"], fraud["risk"])
31
+
32
+ # Batch
33
+ job = client.batch.create(
34
+ type="email",
35
+ rows=["a@example.com", "b@example.com"],
36
+ webhook_url="https://myapp.com/webhook",
37
+ )
38
+ done = client.batch.wait_until_done(job["id"])
39
+ csv = client.batch.download_result(done)
40
+ ```
41
+
42
+ ## Error handling
43
+
44
+ ```python
45
+ from checkharbor import CheckharborError
46
+
47
+ try:
48
+ result = client.validate_email("bad@test.com")
49
+ except CheckharborError as e:
50
+ print(e.code, e.status, str(e))
51
+ # e.g. "insufficient_credits" 402 "Not enough credits"
52
+ ```
53
+
54
+ ## Development
55
+
56
+ ```bash
57
+ python3 -m venv .venv && source .venv/bin/activate
58
+ pip install -e ".[dev]"
59
+ pytest
60
+ ```
@@ -0,0 +1,214 @@
1
+ """
2
+ Checkharbor Python SDK — single-file, zero-dependency beyond `requests`.
3
+
4
+ Usage:
5
+ from checkharbor import Checkharbor, CheckharborError
6
+
7
+ client = Checkharbor(api_key="chk_live_...")
8
+ result = client.validate_email("john@example.com")
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import csv
14
+ import io
15
+ import time
16
+ from typing import Any, Optional, Union
17
+
18
+ import requests
19
+
20
+ __version__ = "0.1.0"
21
+ __all__ = ["Checkharbor", "CheckharborError"]
22
+
23
+ DEFAULT_BASE_URL = "https://api.checkharbor.com"
24
+
25
+
26
+ class CheckharborError(Exception):
27
+ """Raised for any non-2xx response from the Checkharbor API."""
28
+
29
+ def __init__(self, code: str, message: str, status: int) -> None:
30
+ super().__init__(message)
31
+ self.code = code
32
+ self.status = status
33
+
34
+ def __repr__(self) -> str:
35
+ return f"CheckharborError(code={self.code!r}, status={self.status}, message={str(self)!r})"
36
+
37
+
38
+ class _BatchNamespace:
39
+ def __init__(self, client: "Checkharbor") -> None:
40
+ self._client = client
41
+
42
+ def create(
43
+ self,
44
+ *,
45
+ type: str,
46
+ rows: Optional[list[str]] = None,
47
+ csv_string: Optional[str] = None,
48
+ webhook_url: Optional[str] = None,
49
+ ) -> dict[str, Any]:
50
+ """
51
+ Create a batch job.
52
+
53
+ Args:
54
+ type: "email", "phone", or "ip"
55
+ rows: list of values (will be joined as CSV)
56
+ csv_string: raw CSV content (alternative to rows)
57
+ webhook_url: optional callback URL when job completes
58
+ """
59
+ if csv_string is None and not rows:
60
+ raise ValueError("batch.create() requires either rows or csv_string")
61
+
62
+ content = csv_string if csv_string is not None else "\n".join(rows) # type: ignore[arg-type]
63
+
64
+ files = {"file": ("data.csv", io.BytesIO(content.encode()), "text/csv")}
65
+ data: dict[str, str] = {"type": type}
66
+ if webhook_url:
67
+ data["webhook_url"] = webhook_url
68
+
69
+ return self._client._request("POST", "/v1/batch", data=data, files=files)
70
+
71
+ def get(self, job_id: str) -> dict[str, Any]:
72
+ """Fetch the status of a batch job."""
73
+ return self._client._request("GET", f"/v1/batch/{job_id}")
74
+
75
+ def wait_until_done(
76
+ self,
77
+ job_id: str,
78
+ *,
79
+ poll_seconds: float = 2.0,
80
+ timeout_seconds: float = 300.0,
81
+ ) -> dict[str, Any]:
82
+ """
83
+ Block until the batch job is done or failed.
84
+
85
+ Raises:
86
+ CheckharborError: if job fails or timeout is exceeded.
87
+ """
88
+ deadline = time.monotonic() + timeout_seconds
89
+ while True:
90
+ job = self.get(job_id)
91
+ status = job.get("status")
92
+ if status == "done":
93
+ return job
94
+ if status == "failed":
95
+ raise CheckharborError("batch_failed", f"Batch job {job_id} failed", 200)
96
+ if time.monotonic() >= deadline:
97
+ raise CheckharborError(
98
+ "timeout",
99
+ f"Batch job {job_id} timed out after {timeout_seconds}s",
100
+ 0,
101
+ )
102
+ time.sleep(poll_seconds)
103
+
104
+ def download_result(self, job: dict[str, Any]) -> str:
105
+ """
106
+ Download result CSV from a completed batch job.
107
+ The result_url is a signed URL — no auth header needed.
108
+
109
+ Returns:
110
+ CSV content as a string.
111
+ """
112
+ if job.get("status") != "done":
113
+ raise CheckharborError("not_done", "Job is not done yet", 0)
114
+ result_url = job.get("result_url")
115
+ if not result_url:
116
+ raise CheckharborError("no_result_url", "Job has no result_url", 0)
117
+ resp = requests.get(result_url, timeout=30)
118
+ if not resp.ok:
119
+ raise CheckharborError("download_failed", f"Download failed: HTTP {resp.status_code}", resp.status_code)
120
+ return resp.text
121
+
122
+
123
+ class Checkharbor:
124
+ """
125
+ Checkharbor API client.
126
+
127
+ Args:
128
+ api_key: Your Checkharbor API key (X-Api-Key header).
129
+ base_url: Override the default API base URL.
130
+ """
131
+
132
+ def __init__(
133
+ self,
134
+ api_key: str,
135
+ base_url: str = DEFAULT_BASE_URL,
136
+ ) -> None:
137
+ if not api_key:
138
+ raise ValueError("api_key is required")
139
+ self._api_key = api_key
140
+ self._base_url = base_url.rstrip("/")
141
+ self._session = requests.Session()
142
+ self._session.headers["X-Api-Key"] = api_key
143
+ self.batch = _BatchNamespace(self)
144
+
145
+ def _request(
146
+ self,
147
+ method: str,
148
+ path: str,
149
+ json: Optional[dict[str, Any]] = None,
150
+ data: Optional[dict[str, str]] = None,
151
+ files: Optional[Any] = None,
152
+ ) -> dict[str, Any]:
153
+ url = f"{self._base_url}{path}"
154
+ resp = self._session.request(method, url, json=json, data=data, files=files, timeout=30)
155
+ if not resp.ok:
156
+ code = "api_error"
157
+ message = f"HTTP {resp.status_code}"
158
+ try:
159
+ body = resp.json()
160
+ code = body.get("error", {}).get("code", code)
161
+ message = body.get("error", {}).get("message", message)
162
+ except Exception:
163
+ pass
164
+ raise CheckharborError(code, message, resp.status_code)
165
+ return resp.json() # type: ignore[return-value]
166
+
167
+ def ping(self) -> dict[str, Any]:
168
+ """Health check — returns ok + credits_remaining."""
169
+ return self._request("GET", "/v1/ping")
170
+
171
+ def validate_email(self, email: str) -> dict[str, Any]:
172
+ """Validate a single email address (1 credit)."""
173
+ return self._request("POST", "/v1/email/validate", json={"email": email})
174
+
175
+ def validate_phone(
176
+ self,
177
+ phone: str,
178
+ *,
179
+ country_hint: Optional[str] = None,
180
+ hlr: Optional[bool] = None,
181
+ ) -> dict[str, Any]:
182
+ """Validate a phone number (1 credit offline, 5 with HLR)."""
183
+ payload: dict[str, Any] = {"phone": phone}
184
+ if country_hint is not None:
185
+ payload["country_hint"] = country_hint
186
+ if hlr is not None:
187
+ payload["hlr"] = hlr
188
+ return self._request("POST", "/v1/phone/validate", json=payload)
189
+
190
+ def ip_intel(self, ip: str) -> dict[str, Any]:
191
+ """IP intelligence lookup (1 credit)."""
192
+ return self._request("POST", "/v1/ip/intel", json={"ip": ip})
193
+
194
+ def verify(
195
+ self,
196
+ *,
197
+ email: Optional[str] = None,
198
+ phone: Optional[str] = None,
199
+ ip: Optional[str] = None,
200
+ ) -> dict[str, Any]:
201
+ """
202
+ Unified fraud scoring (3 credits).
203
+ At least one of email, phone, ip is required.
204
+ """
205
+ if not any([email, phone, ip]):
206
+ raise ValueError("verify() requires at least one of: email, phone, ip")
207
+ payload: dict[str, Any] = {}
208
+ if email:
209
+ payload["email"] = email
210
+ if phone:
211
+ payload["phone"] = phone
212
+ if ip:
213
+ payload["ip"] = ip
214
+ return self._request("POST", "/v1/verify", json=payload)
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: checkharbor
3
+ Version: 0.1.0
4
+ Summary: Official Checkharbor Python SDK — email/phone/IP validation
5
+ License: MIT
6
+ Project-URL: Homepage, https://checkharbor.com
7
+ Project-URL: Documentation, https://docs.checkharbor.com
8
+ Project-URL: Repository, https://github.com/checkharbor/checkharbor-python
9
+ Keywords: checkharbor,email-validation,phone-validation,ip-intelligence
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: requests>=2.28
13
+
14
+ # checkharbor
15
+
16
+ Official Checkharbor Python SDK.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install checkharbor
22
+ ```
23
+
24
+ ## Quick start
25
+
26
+ ```python
27
+ from checkharbor import Checkharbor
28
+
29
+ client = Checkharbor(api_key="chk_live_...")
30
+
31
+ # Validate email
32
+ email = client.validate_email("john@example.com")
33
+ print(email["score"], email["deliverability"])
34
+
35
+ # Validate phone
36
+ phone = client.validate_phone("+905321234567", country_hint="TR")
37
+
38
+ # IP intelligence
39
+ ip = client.ip_intel("84.17.45.10")
40
+
41
+ # Unified fraud check
42
+ fraud = client.verify(email="john@example.com", ip="84.17.45.10")
43
+ print(fraud["fraud_score"], fraud["risk"])
44
+
45
+ # Batch
46
+ job = client.batch.create(
47
+ type="email",
48
+ rows=["a@example.com", "b@example.com"],
49
+ webhook_url="https://myapp.com/webhook",
50
+ )
51
+ done = client.batch.wait_until_done(job["id"])
52
+ csv = client.batch.download_result(done)
53
+ ```
54
+
55
+ ## Error handling
56
+
57
+ ```python
58
+ from checkharbor import CheckharborError
59
+
60
+ try:
61
+ result = client.validate_email("bad@test.com")
62
+ except CheckharborError as e:
63
+ print(e.code, e.status, str(e))
64
+ # e.g. "insufficient_credits" 402 "Not enough credits"
65
+ ```
66
+
67
+ ## Development
68
+
69
+ ```bash
70
+ python3 -m venv .venv && source .venv/bin/activate
71
+ pip install -e ".[dev]"
72
+ pytest
73
+ ```
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ checkharbor/__init__.py
4
+ checkharbor.egg-info/PKG-INFO
5
+ checkharbor.egg-info/SOURCES.txt
6
+ checkharbor.egg-info/dependency_links.txt
7
+ checkharbor.egg-info/requires.txt
8
+ checkharbor.egg-info/top_level.txt
9
+ tests/test_checkharbor.py
@@ -0,0 +1 @@
1
+ requests>=2.28
@@ -0,0 +1 @@
1
+ checkharbor
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "checkharbor"
7
+ version = "0.1.0"
8
+ description = "Official Checkharbor Python SDK — email/phone/IP validation"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["checkharbor", "email-validation", "phone-validation", "ip-intelligence"]
13
+ dependencies = ["requests>=2.28"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://checkharbor.com"
17
+ Documentation = "https://docs.checkharbor.com"
18
+ Repository = "https://github.com/checkharbor/checkharbor-python"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["."]
22
+ include = ["checkharbor*"]
23
+
24
+ [tool.pytest.ini_options]
25
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,266 @@
1
+ """Minimal pytest tests for the Checkharbor Python SDK using unittest.mock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import time
7
+ from unittest.mock import MagicMock, patch, call
8
+ import pytest
9
+
10
+ from checkharbor import Checkharbor, CheckharborError
11
+
12
+
13
+ # ── fixtures ──────────────────────────────────────────────────────────────────
14
+
15
+ EMAIL_OK = {
16
+ "input": "test@example.com",
17
+ "valid_syntax": True,
18
+ "score": 90,
19
+ "credits_used": 1,
20
+ }
21
+
22
+ PHONE_OK = {
23
+ "input": "+905321234567",
24
+ "valid": True,
25
+ "e164": "+905321234567",
26
+ "country": "TR",
27
+ "line_type": "mobile",
28
+ "credits_used": 1,
29
+ }
30
+
31
+ IP_OK = {
32
+ "input": "84.17.45.10",
33
+ "vpn": False,
34
+ "proxy": False,
35
+ "tor": False,
36
+ "datacenter": False,
37
+ "abuse_score": 5,
38
+ "credits_used": 1,
39
+ }
40
+
41
+ VERIFY_OK = {
42
+ "fraud_score": 20,
43
+ "risk": "low",
44
+ "credits_used": 3,
45
+ }
46
+
47
+ BATCH_QUEUED = {
48
+ "id": "job_123",
49
+ "status": "queued",
50
+ "type": "email",
51
+ "total_rows": 3,
52
+ "processed_rows": 0,
53
+ "result_url": None,
54
+ }
55
+
56
+ BATCH_DONE = {
57
+ **BATCH_QUEUED,
58
+ "status": "done",
59
+ "processed_rows": 3,
60
+ "result_url": "https://cdn.checkharbor.com/results/job_123.csv?sig=abc",
61
+ }
62
+
63
+
64
+ def make_response(body: dict, status: int = 200) -> MagicMock:
65
+ resp = MagicMock()
66
+ resp.ok = 200 <= status < 300
67
+ resp.status_code = status
68
+ resp.json.return_value = body
69
+ resp.text = "csv,data\na,b"
70
+ return resp
71
+
72
+
73
+ @pytest.fixture
74
+ def client() -> Checkharbor:
75
+ return Checkharbor(api_key="test-key", base_url="http://localhost:4000")
76
+
77
+
78
+ # ── constructor ───────────────────────────────────────────────────────────────
79
+
80
+ def test_constructor_requires_api_key():
81
+ with pytest.raises(ValueError, match="api_key is required"):
82
+ Checkharbor(api_key="")
83
+
84
+
85
+ def test_constructor_strips_trailing_slash():
86
+ c = Checkharbor(api_key="k", base_url="http://localhost:4000/")
87
+ assert c._base_url == "http://localhost:4000"
88
+
89
+
90
+ # ── ping ──────────────────────────────────────────────────────────────────────
91
+
92
+ def test_ping(client: Checkharbor):
93
+ with patch.object(client._session, "request", return_value=make_response({"ok": True, "credits_remaining": 500})) as mock:
94
+ result = client.ping()
95
+ assert result["ok"] is True
96
+ assert result["credits_remaining"] == 500
97
+ mock.assert_called_once_with("GET", "http://localhost:4000/v1/ping", json=None, data=None, files=None, timeout=30)
98
+
99
+
100
+ # ── validate_email ────────────────────────────────────────────────────────────
101
+
102
+ def test_validate_email(client: Checkharbor):
103
+ with patch.object(client._session, "request", return_value=make_response(EMAIL_OK)) as mock:
104
+ result = client.validate_email("test@example.com")
105
+ assert result["valid_syntax"] is True
106
+ assert result["score"] == 90
107
+ mock.assert_called_once_with(
108
+ "POST", "http://localhost:4000/v1/email/validate",
109
+ json={"email": "test@example.com"}, data=None, files=None, timeout=30
110
+ )
111
+
112
+
113
+ # ── validate_phone ────────────────────────────────────────────────────────────
114
+
115
+ def test_validate_phone_with_options(client: Checkharbor):
116
+ with patch.object(client._session, "request", return_value=make_response(PHONE_OK)) as mock:
117
+ result = client.validate_phone("+905321234567", country_hint="TR", hlr=False)
118
+ assert result["valid"] is True
119
+ mock.assert_called_once_with(
120
+ "POST", "http://localhost:4000/v1/phone/validate",
121
+ json={"phone": "+905321234567", "country_hint": "TR", "hlr": False},
122
+ data=None, files=None, timeout=30
123
+ )
124
+
125
+
126
+ def test_validate_phone_minimal(client: Checkharbor):
127
+ with patch.object(client._session, "request", return_value=make_response(PHONE_OK)) as mock:
128
+ client.validate_phone("+905321234567")
129
+ called_json = mock.call_args.kwargs["json"]
130
+ assert called_json == {"phone": "+905321234567"}
131
+
132
+
133
+ # ── ip_intel ──────────────────────────────────────────────────────────────────
134
+
135
+ def test_ip_intel(client: Checkharbor):
136
+ with patch.object(client._session, "request", return_value=make_response(IP_OK)):
137
+ result = client.ip_intel("84.17.45.10")
138
+ assert result["input"] == "84.17.45.10"
139
+ assert result["credits_used"] == 1
140
+
141
+
142
+ # ── verify ────────────────────────────────────────────────────────────────────
143
+
144
+ def test_verify(client: Checkharbor):
145
+ with patch.object(client._session, "request", return_value=make_response(VERIFY_OK)) as mock:
146
+ result = client.verify(email="test@example.com", ip="1.2.3.4")
147
+ assert result["fraud_score"] == 20
148
+ assert result["risk"] == "low"
149
+
150
+
151
+ def test_verify_requires_at_least_one_field(client: Checkharbor):
152
+ with pytest.raises(ValueError, match="at least one of"):
153
+ client.verify()
154
+
155
+
156
+ # ── batch ─────────────────────────────────────────────────────────────────────
157
+
158
+ def test_batch_create_with_rows(client: Checkharbor):
159
+ with patch.object(client._session, "request", return_value=make_response(BATCH_QUEUED, 202)) as mock:
160
+ job = client.batch.create(
161
+ type="email",
162
+ rows=["a@example.com", "b@example.com", "c@example.com"],
163
+ )
164
+ assert job["id"] == "job_123"
165
+ assert job["status"] == "queued"
166
+ # Confirm multipart call (data + files, no json)
167
+ call_kwargs = mock.call_args.kwargs
168
+ assert call_kwargs["json"] is None
169
+ assert call_kwargs["data"] == {"type": "email"}
170
+ assert "file" in call_kwargs["files"]
171
+
172
+
173
+ def test_batch_create_with_csv_string(client: Checkharbor):
174
+ with patch.object(client._session, "request", return_value=make_response(BATCH_QUEUED, 202)) as mock:
175
+ client.batch.create(type="email", csv_string="a@b.com\nc@d.com")
176
+ call_kwargs = mock.call_args.kwargs
177
+ assert call_kwargs["data"] == {"type": "email"}
178
+
179
+
180
+ def test_batch_create_raises_without_data(client: Checkharbor):
181
+ with pytest.raises(ValueError, match="rows or csv_string"):
182
+ client.batch.create(type="email")
183
+
184
+
185
+ def test_batch_get(client: Checkharbor):
186
+ with patch.object(client._session, "request", return_value=make_response(BATCH_DONE)) as mock:
187
+ job = client.batch.get("job_123")
188
+ assert job["status"] == "done"
189
+ mock.assert_called_once_with(
190
+ "GET", "http://localhost:4000/v1/batch/job_123",
191
+ json=None, data=None, files=None, timeout=30
192
+ )
193
+
194
+
195
+ def test_batch_wait_until_done_polls(client: Checkharbor):
196
+ responses = [
197
+ make_response(BATCH_QUEUED),
198
+ make_response({**BATCH_QUEUED, "status": "processing"}),
199
+ make_response(BATCH_DONE),
200
+ ]
201
+ with patch.object(client._session, "request", side_effect=responses):
202
+ with patch("time.sleep"): # don't actually sleep
203
+ job = client.batch.wait_until_done("job_123", poll_seconds=0.001)
204
+ assert job["status"] == "done"
205
+
206
+
207
+ def test_batch_wait_until_done_fails(client: Checkharbor):
208
+ failed = {**BATCH_QUEUED, "status": "failed"}
209
+ with patch.object(client._session, "request", return_value=make_response(failed)):
210
+ with pytest.raises(CheckharborError) as exc_info:
211
+ client.batch.wait_until_done("job_123", poll_seconds=0.001)
212
+ assert exc_info.value.code == "batch_failed"
213
+
214
+
215
+ def test_batch_wait_until_done_timeout(client: Checkharbor):
216
+ with patch.object(client._session, "request", return_value=make_response(BATCH_QUEUED)):
217
+ with patch("time.sleep"):
218
+ with patch("time.monotonic", side_effect=[0.0, 0.0, 1000.0]):
219
+ with pytest.raises(CheckharborError) as exc_info:
220
+ client.batch.wait_until_done("job_123", poll_seconds=0.001, timeout_seconds=1.0)
221
+ assert exc_info.value.code == "timeout"
222
+
223
+
224
+ def test_batch_download_result(client: Checkharbor):
225
+ csv_content = "email,valid\na@b.com,true"
226
+ download_resp = MagicMock()
227
+ download_resp.ok = True
228
+ download_resp.status_code = 200
229
+ download_resp.text = csv_content
230
+
231
+ with patch("requests.get", return_value=download_resp) as mock_get:
232
+ result = client.batch.download_result(BATCH_DONE)
233
+ assert result == csv_content
234
+ mock_get.assert_called_once_with(BATCH_DONE["result_url"], timeout=30)
235
+
236
+
237
+ def test_batch_download_result_raises_if_not_done(client: Checkharbor):
238
+ with pytest.raises(CheckharborError) as exc_info:
239
+ client.batch.download_result(BATCH_QUEUED)
240
+ assert exc_info.value.code == "not_done"
241
+
242
+
243
+ # ── error handling ────────────────────────────────────────────────────────────
244
+
245
+ def test_raises_checkharbor_error_on_402(client: Checkharbor):
246
+ err_resp = make_response(
247
+ {"error": {"code": "insufficient_credits", "message": "Not enough credits"}},
248
+ status=402,
249
+ )
250
+ with patch.object(client._session, "request", return_value=err_resp):
251
+ with pytest.raises(CheckharborError) as exc_info:
252
+ client.validate_email("x@y.com")
253
+ assert exc_info.value.code == "insufficient_credits"
254
+ assert exc_info.value.status == 402
255
+
256
+
257
+ def test_raises_checkharbor_error_on_429(client: Checkharbor):
258
+ err_resp = make_response(
259
+ {"error": {"code": "rate_limited", "message": "Too many requests"}},
260
+ status=429,
261
+ )
262
+ with patch.object(client._session, "request", return_value=err_resp):
263
+ with pytest.raises(CheckharborError) as exc_info:
264
+ client.ip_intel("1.2.3.4")
265
+ assert exc_info.value.code == "rate_limited"
266
+ assert exc_info.value.status == 429