Skip to content

Commit 73cdfae

Browse files
feat(sdk): add synchronous ValidKit client (#1)
Add a sync wrapper around AsyncValidKit using a dedicated background event loop thread. This makes `from validkit import ValidKit` work — matching the landing page examples that have shown sync usage for months. Methods: verify(), verify_batch(), get_batch_status(), get_batch_results(), cancel_batch(). Context manager support. Works inside Jupyter notebooks and Django views where an event loop is already running (tested in TestNestedEventLoop). Closes ValidKit/validkit#2256
1 parent 1964e83 commit 73cdfae

10 files changed

Lines changed: 402 additions & 23 deletions

File tree

README.md

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[![Python Versions](https://img.shields.io/pypi/pyversions/validkit.svg)](https://pypi.org/project/validkit/)
55
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
66

7-
Email validation for signup flows -- block junk without blocking `test+staging@example.com`. Async Python client with batch support up to 10K emails, automatic retries, and Pydantic models.
7+
Email validation for signup flows -- block junk without blocking `test+staging@example.com`. Sync and async clients, batch support up to 10K emails, automatic retries, and Pydantic models.
88

99
## Installation
1010

@@ -16,31 +16,54 @@ Requires Python 3.8+.
1616

1717
## Quick Start
1818

19+
```python
20+
from validkit import ValidKit
21+
22+
client = ValidKit("your_api_key")
23+
result = client.verify("user@example.com")
24+
print(result.v) # True or False
25+
client.close()
26+
```
27+
28+
Or with a context manager:
29+
30+
```python
31+
from validkit import ValidKit
32+
33+
with ValidKit("your_api_key") as client:
34+
# Single email
35+
result = client.verify("user@example.com")
36+
print(result.v)
37+
38+
# Batch -- compact format by default
39+
results = client.verify_batch([
40+
"alice@company.com",
41+
"bob@tempmail.com",
42+
"not-an-email",
43+
])
44+
for email, r in results.items():
45+
print(f"{email}: valid={r.v}, disposable={r.d}")
46+
```
47+
48+
### Async usage
49+
50+
For high-throughput applications, use `AsyncValidKit` directly:
51+
1952
```python
2053
import asyncio
2154
from validkit import AsyncValidKit
2255

2356
async def main():
2457
async with AsyncValidKit(api_key="your_api_key") as client:
25-
# Single email
2658
result = await client.verify_email("user@example.com")
27-
print(result.valid) # True
28-
29-
# Batch -- compact format by default
30-
results = await client.verify_batch([
31-
"alice@company.com",
32-
"bob@tempmail.com",
33-
"not-an-email",
34-
])
35-
for email, r in results.items():
36-
print(f"{email}: valid={r.v}, disposable={r.d}")
59+
print(result.valid)
3760

3861
asyncio.run(main())
3962
```
4063

4164
## Features
4265

43-
- **Async-native** -- aiohttp with connection pooling (100 connections default)
66+
- **Sync and async** -- `ValidKit` for scripts, `AsyncValidKit` for high-throughput
4467
- **Batch verification** -- up to 10,000 emails per call, chunked automatically
4568
- **Developer Pattern Intelligence** -- understands `test@`, `+addressing`, disposable domains
4669
- **Compact format** -- token-efficient responses (`v`, `d`, `r` fields) enabled by default

setup.cfg

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
[metadata]
22
name = validkit
3-
version = 1.1.3
3+
version = 1.2.0
44
author = ValidKit
55
author_email = developers@validkit.com
6-
description = Async Python SDK for ValidKit Email Verification API - Built for AI Agents
6+
description = Python SDK for ValidKit Email Verification API - Built for AI Agents
77
long_description = file: README.md
88
long_description_content_type = text/markdown
99
url = https://github.com/ValidKit/validkit-python-sdk

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99

1010
setup(
1111
name="validkit",
12-
version="1.1.3",
12+
version="1.2.0",
1313
author="ValidKit",
1414
author_email="developers@validkit.com",
15-
description="Async Python SDK for ValidKit Email Verification API - Built for AI Agents",
15+
description="Python SDK for ValidKit Email Verification API - Built for AI Agents",
1616
long_description=long_description,
1717
long_description_content_type="text/markdown",
1818
url="https://github.com/ValidKit/validkit-python-sdk",

tests/conftest.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44
from unittest.mock import AsyncMock, patch
5-
from validkit import AsyncValidKit
5+
from validkit import AsyncValidKit, ValidKit
66
from validkit.config import ValidKitConfig
77

88

@@ -21,6 +21,14 @@ def client(api_key):
2121
return AsyncValidKit(api_key=api_key)
2222

2323

24+
@pytest.fixture
25+
def sync_client(mock_request):
26+
"""Create a sync ValidKit client with mocked HTTP."""
27+
client = ValidKit(api_key="vk_test_abc123")
28+
yield client
29+
client.close()
30+
31+
2432
@pytest.fixture
2533
def mock_request():
2634
"""Patch AsyncValidKit._request to capture calls without making HTTP requests."""

tests/test_config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,9 @@ def test_defaults(self):
3939

4040
def test_user_agent_contains_version(self):
4141
config = ValidKitConfig(api_key="test")
42-
assert "1.1.3" in config.user_agent
42+
assert "1.2.0" in config.user_agent
4343

4444
def test_headers_include_sdk_version(self):
4545
config = ValidKitConfig(api_key="test")
46-
assert config.headers["X-SDK-Version"] == "1.1.3"
46+
assert config.headers["X-SDK-Version"] == "1.2.0"
4747
assert config.headers["X-SDK-Language"] == "python"

tests/test_imports.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
"""
66

77

8+
def test_import_validkit_sync():
9+
from validkit import ValidKit
10+
assert ValidKit is not None
11+
12+
813
def test_import_async_validkit():
914
from validkit import AsyncValidKit
1015
assert AsyncValidKit is not None
@@ -48,7 +53,7 @@ def test_import_exceptions():
4853

4954
def test_version():
5055
from validkit import __version__
51-
assert __version__ == "1.1.3"
56+
assert __version__ == "1.2.0"
5257

5358

5459
def test_version_single_source_of_truth():

tests/test_sync_client.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""Tests for synchronous ValidKit client.
2+
3+
Mirrors test_client.py patterns — mocks AsyncValidKit._request to
4+
verify the sync wrapper delegates correctly without making HTTP calls.
5+
"""
6+
7+
import asyncio
8+
import pytest
9+
from unittest.mock import AsyncMock, patch
10+
from datetime import datetime
11+
12+
from validkit import ValidKit
13+
from validkit.client import AsyncValidKit
14+
from validkit.config import ValidKitConfig
15+
from validkit.models import (
16+
CompactResult,
17+
EmailVerificationResult,
18+
BatchJob,
19+
BatchVerificationResult,
20+
BatchJobStatus,
21+
ResponseFormat,
22+
)
23+
from validkit.exceptions import BatchSizeError
24+
25+
26+
class TestSyncClientInit:
27+
def test_requires_api_key(self):
28+
with pytest.raises(ValueError, match="API key"):
29+
ValidKit()
30+
31+
def test_accepts_api_key_string(self, mock_request):
32+
client = ValidKit(api_key="vk_test_123")
33+
assert client._async_client.config.api_key == "vk_test_123"
34+
client.close()
35+
36+
def test_accepts_config_object(self, mock_request):
37+
config = ValidKitConfig(api_key="vk_test_456", timeout=60)
38+
client = ValidKit(config=config)
39+
assert client._async_client.config.timeout == 60
40+
client.close()
41+
42+
43+
class TestSyncVerify:
44+
def test_returns_compact_result(self, sync_client, mock_request):
45+
mock_request.return_value = {"result": {"v": True, "d": False}}
46+
result = sync_client.verify("user@test.com")
47+
assert isinstance(result, CompactResult)
48+
assert result.v is True
49+
50+
def test_calls_correct_endpoint(self, sync_client, mock_request):
51+
mock_request.return_value = {"result": {"v": True}}
52+
sync_client.verify("test@example.com")
53+
mock_request.assert_called_once()
54+
args = mock_request.call_args
55+
assert args[0][0] == "POST"
56+
assert args[0][1] == "verify"
57+
58+
def test_full_format_with_compact_disabled(self, mock_request):
59+
config = ValidKitConfig(api_key="test", compact_format=False)
60+
client = ValidKit(config=config)
61+
mock_request.return_value = {
62+
"success": True,
63+
"email": "t@t.com",
64+
"valid": True,
65+
}
66+
result = client.verify("t@t.com")
67+
assert isinstance(result, EmailVerificationResult)
68+
client.close()
69+
70+
71+
class TestSyncVerifyBatch:
72+
def test_calls_agent_bulk_endpoint(self, sync_client, mock_request):
73+
mock_request.return_value = {
74+
"results": {"a@b.com": {"v": True}},
75+
}
76+
sync_client.verify_batch(["a@b.com"])
77+
args = mock_request.call_args
78+
assert args[0][0] == "POST"
79+
assert args[0][1] == "verify/bulk/agent"
80+
81+
def test_rejects_oversized_batch(self, sync_client, mock_request):
82+
emails = [f"user{i}@test.com" for i in range(10001)]
83+
with pytest.raises(BatchSizeError):
84+
sync_client.verify_batch(emails)
85+
86+
def test_rejects_async_progress_callback(self, sync_client, mock_request):
87+
async def bad_callback(processed, total):
88+
pass
89+
90+
with pytest.raises(TypeError, match="plain function"):
91+
sync_client.verify_batch(["a@b.com"], progress_callback=bad_callback)
92+
93+
def test_sync_progress_callback(self, sync_client, mock_request):
94+
mock_request.return_value = {
95+
"results": {"a@b.com": {"v": True}},
96+
}
97+
called = []
98+
99+
def callback(processed, total):
100+
called.append((processed, total))
101+
102+
sync_client.verify_batch(["a@b.com"], progress_callback=callback)
103+
assert len(called) == 1
104+
assert called[0] == (1, 1)
105+
106+
107+
class TestSyncBatchLifecycle:
108+
def _batch_job_response(self, status="processing"):
109+
now = datetime.now().isoformat()
110+
return {
111+
"id": "batch-abc",
112+
"status": status,
113+
"total_emails": 100,
114+
"processed": 50,
115+
"created_at": now,
116+
"updated_at": now,
117+
}
118+
119+
def test_get_batch_status(self, sync_client, mock_request):
120+
mock_request.return_value = self._batch_job_response("processing")
121+
result = sync_client.get_batch_status("batch-abc")
122+
assert isinstance(result, BatchJob)
123+
args = mock_request.call_args
124+
assert args[0][0] == "GET"
125+
assert args[0][1] == "batch/batch-abc"
126+
127+
def test_get_batch_results(self, sync_client, mock_request):
128+
mock_request.return_value = {
129+
"success": True,
130+
"total": 1,
131+
"valid": 1,
132+
"invalid": 0,
133+
"results": {"a@b.com": {"v": True}},
134+
}
135+
result = sync_client.get_batch_results("batch-abc")
136+
assert isinstance(result, BatchVerificationResult)
137+
args = mock_request.call_args
138+
assert args[0][0] == "GET"
139+
assert args[0][1] == "batch/batch-abc/results"
140+
141+
def test_cancel_batch_uses_delete(self, sync_client, mock_request):
142+
"""Regression: cancel_batch must use DELETE (#2316)."""
143+
mock_request.return_value = self._batch_job_response("cancelled")
144+
sync_client.cancel_batch("batch-abc")
145+
args = mock_request.call_args
146+
assert args[0][0] == "DELETE"
147+
148+
149+
class TestSyncContextManager:
150+
def test_with_statement(self, mock_request):
151+
mock_request.return_value = {"result": {"v": True}}
152+
with ValidKit(api_key="vk_test_123") as client:
153+
result = client.verify("test@example.com")
154+
assert result.v is True
155+
# After exiting, the runner thread should be stopped
156+
157+
def test_close_is_idempotent(self, mock_request):
158+
client = ValidKit(api_key="vk_test_123")
159+
client.close()
160+
# Second close should not raise
161+
client.close()
162+
163+
164+
class TestNestedEventLoop:
165+
def test_works_inside_existing_event_loop(self, mock_request):
166+
"""ValidKit must work when an event loop is already running.
167+
168+
This is the critical test for Jupyter / Django compatibility.
169+
We simulate an outer loop by running the test body inside one.
170+
"""
171+
mock_request.return_value = {"result": {"v": True}}
172+
173+
async def _inner():
174+
# An event loop is now running on THIS thread.
175+
client = ValidKit(api_key="vk_test_123")
176+
result = client.verify("test@example.com")
177+
assert result.v is True
178+
client.close()
179+
180+
asyncio.run(_inner())

validkit/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""
22
ValidKit Python SDK
3-
Async email verification for AI agents and high-volume applications
3+
Email verification for AI agents and high-volume applications
44
"""
55

6+
from .sync_client import ValidKit
67
from .client import AsyncValidKit
78
from .config import ValidKitConfig
89
from .models import (
@@ -31,6 +32,7 @@
3132

3233
__all__ = [
3334
# Client
35+
"ValidKit",
3436
"AsyncValidKit",
3537
"ValidKitConfig",
3638

validkit/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Single source of truth for the SDK version."""
22

3-
__version__ = "1.1.3"
3+
__version__ = "1.2.0"

0 commit comments

Comments
 (0)