Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![Python Versions](https://img.shields.io/pypi/pyversions/validkit.svg)](https://pypi.org/project/validkit/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

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.
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.

## Installation

Expand All @@ -16,31 +16,54 @@ Requires Python 3.8+.

## Quick Start

```python
from validkit import ValidKit

client = ValidKit("your_api_key")
result = client.verify("user@example.com")
print(result.v) # True or False
client.close()
```

Or with a context manager:

```python
from validkit import ValidKit

with ValidKit("your_api_key") as client:
# Single email
result = client.verify("user@example.com")
print(result.v)

# Batch -- compact format by default
results = client.verify_batch([
"alice@company.com",
"bob@tempmail.com",
"not-an-email",
])
for email, r in results.items():
print(f"{email}: valid={r.v}, disposable={r.d}")
```

### Async usage

For high-throughput applications, use `AsyncValidKit` directly:

```python
import asyncio
from validkit import AsyncValidKit

async def main():
async with AsyncValidKit(api_key="your_api_key") as client:
# Single email
result = await client.verify_email("user@example.com")
print(result.valid) # True

# Batch -- compact format by default
results = await client.verify_batch([
"alice@company.com",
"bob@tempmail.com",
"not-an-email",
])
for email, r in results.items():
print(f"{email}: valid={r.v}, disposable={r.d}")
print(result.valid)

asyncio.run(main())
```

## Features

- **Async-native** -- aiohttp with connection pooling (100 connections default)
- **Sync and async** -- `ValidKit` for scripts, `AsyncValidKit` for high-throughput
- **Batch verification** -- up to 10,000 emails per call, chunked automatically
- **Developer Pattern Intelligence** -- understands `test@`, `+addressing`, disposable domains
- **Compact format** -- token-efficient responses (`v`, `d`, `r` fields) enabled by default
Expand Down
4 changes: 2 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
[metadata]
name = validkit
version = 1.1.3
version = 1.2.0
author = ValidKit
author_email = developers@validkit.com
description = Async Python SDK for ValidKit Email Verification API - Built for AI Agents
description = Python SDK for ValidKit Email Verification API - Built for AI Agents
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/ValidKit/validkit-python-sdk
Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@

setup(
name="validkit",
version="1.1.3",
version="1.2.0",
author="ValidKit",
author_email="developers@validkit.com",
description="Async Python SDK for ValidKit Email Verification API - Built for AI Agents",
description="Python SDK for ValidKit Email Verification API - Built for AI Agents",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/ValidKit/validkit-python-sdk",
Expand Down
10 changes: 9 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import pytest
from unittest.mock import AsyncMock, patch
from validkit import AsyncValidKit
from validkit import AsyncValidKit, ValidKit
from validkit.config import ValidKitConfig


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


@pytest.fixture
def sync_client(mock_request):
"""Create a sync ValidKit client with mocked HTTP."""
client = ValidKit(api_key="vk_test_abc123")
yield client
client.close()


@pytest.fixture
def mock_request():
"""Patch AsyncValidKit._request to capture calls without making HTTP requests."""
Expand Down
4 changes: 2 additions & 2 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ def test_defaults(self):

def test_user_agent_contains_version(self):
config = ValidKitConfig(api_key="test")
assert "1.1.3" in config.user_agent
assert "1.2.0" in config.user_agent

def test_headers_include_sdk_version(self):
config = ValidKitConfig(api_key="test")
assert config.headers["X-SDK-Version"] == "1.1.3"
assert config.headers["X-SDK-Version"] == "1.2.0"
assert config.headers["X-SDK-Language"] == "python"
7 changes: 6 additions & 1 deletion tests/test_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"""


def test_import_validkit_sync():
from validkit import ValidKit
assert ValidKit is not None


def test_import_async_validkit():
from validkit import AsyncValidKit
assert AsyncValidKit is not None
Expand Down Expand Up @@ -48,7 +53,7 @@ def test_import_exceptions():

def test_version():
from validkit import __version__
assert __version__ == "1.1.3"
assert __version__ == "1.2.0"


def test_version_single_source_of_truth():
Expand Down
180 changes: 180 additions & 0 deletions tests/test_sync_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
"""Tests for synchronous ValidKit client.

Mirrors test_client.py patterns — mocks AsyncValidKit._request to
verify the sync wrapper delegates correctly without making HTTP calls.
"""

import asyncio
import pytest
from unittest.mock import AsyncMock, patch
from datetime import datetime

from validkit import ValidKit
from validkit.client import AsyncValidKit
from validkit.config import ValidKitConfig
from validkit.models import (
CompactResult,
EmailVerificationResult,
BatchJob,
BatchVerificationResult,
BatchJobStatus,
ResponseFormat,
)
from validkit.exceptions import BatchSizeError


class TestSyncClientInit:
def test_requires_api_key(self):
with pytest.raises(ValueError, match="API key"):
ValidKit()

def test_accepts_api_key_string(self, mock_request):
client = ValidKit(api_key="vk_test_123")
assert client._async_client.config.api_key == "vk_test_123"
client.close()

def test_accepts_config_object(self, mock_request):
config = ValidKitConfig(api_key="vk_test_456", timeout=60)
client = ValidKit(config=config)
assert client._async_client.config.timeout == 60
client.close()


class TestSyncVerify:
def test_returns_compact_result(self, sync_client, mock_request):
mock_request.return_value = {"result": {"v": True, "d": False}}
result = sync_client.verify("user@test.com")
assert isinstance(result, CompactResult)
assert result.v is True

def test_calls_correct_endpoint(self, sync_client, mock_request):
mock_request.return_value = {"result": {"v": True}}
sync_client.verify("test@example.com")
mock_request.assert_called_once()
args = mock_request.call_args
assert args[0][0] == "POST"
assert args[0][1] == "verify"

def test_full_format_with_compact_disabled(self, mock_request):
config = ValidKitConfig(api_key="test", compact_format=False)
client = ValidKit(config=config)
mock_request.return_value = {
"success": True,
"email": "t@t.com",
"valid": True,
}
result = client.verify("t@t.com")
assert isinstance(result, EmailVerificationResult)
client.close()


class TestSyncVerifyBatch:
def test_calls_agent_bulk_endpoint(self, sync_client, mock_request):
mock_request.return_value = {
"results": {"a@b.com": {"v": True}},
}
sync_client.verify_batch(["a@b.com"])
args = mock_request.call_args
assert args[0][0] == "POST"
assert args[0][1] == "verify/bulk/agent"

def test_rejects_oversized_batch(self, sync_client, mock_request):
emails = [f"user{i}@test.com" for i in range(10001)]
with pytest.raises(BatchSizeError):
sync_client.verify_batch(emails)

def test_rejects_async_progress_callback(self, sync_client, mock_request):
async def bad_callback(processed, total):
pass

with pytest.raises(TypeError, match="plain function"):
sync_client.verify_batch(["a@b.com"], progress_callback=bad_callback)

def test_sync_progress_callback(self, sync_client, mock_request):
mock_request.return_value = {
"results": {"a@b.com": {"v": True}},
}
called = []

def callback(processed, total):
called.append((processed, total))

sync_client.verify_batch(["a@b.com"], progress_callback=callback)
assert len(called) == 1
assert called[0] == (1, 1)


class TestSyncBatchLifecycle:
def _batch_job_response(self, status="processing"):
now = datetime.now().isoformat()
return {
"id": "batch-abc",
"status": status,
"total_emails": 100,
"processed": 50,
"created_at": now,
"updated_at": now,
}

def test_get_batch_status(self, sync_client, mock_request):
mock_request.return_value = self._batch_job_response("processing")
result = sync_client.get_batch_status("batch-abc")
assert isinstance(result, BatchJob)
args = mock_request.call_args
assert args[0][0] == "GET"
assert args[0][1] == "batch/batch-abc"

def test_get_batch_results(self, sync_client, mock_request):
mock_request.return_value = {
"success": True,
"total": 1,
"valid": 1,
"invalid": 0,
"results": {"a@b.com": {"v": True}},
}
result = sync_client.get_batch_results("batch-abc")
assert isinstance(result, BatchVerificationResult)
args = mock_request.call_args
assert args[0][0] == "GET"
assert args[0][1] == "batch/batch-abc/results"

def test_cancel_batch_uses_delete(self, sync_client, mock_request):
"""Regression: cancel_batch must use DELETE (#2316)."""
mock_request.return_value = self._batch_job_response("cancelled")
sync_client.cancel_batch("batch-abc")
args = mock_request.call_args
assert args[0][0] == "DELETE"


class TestSyncContextManager:
def test_with_statement(self, mock_request):
mock_request.return_value = {"result": {"v": True}}
with ValidKit(api_key="vk_test_123") as client:
result = client.verify("test@example.com")
assert result.v is True
# After exiting, the runner thread should be stopped

def test_close_is_idempotent(self, mock_request):
client = ValidKit(api_key="vk_test_123")
client.close()
# Second close should not raise
client.close()


class TestNestedEventLoop:
def test_works_inside_existing_event_loop(self, mock_request):
"""ValidKit must work when an event loop is already running.

This is the critical test for Jupyter / Django compatibility.
We simulate an outer loop by running the test body inside one.
"""
mock_request.return_value = {"result": {"v": True}}

async def _inner():
# An event loop is now running on THIS thread.
client = ValidKit(api_key="vk_test_123")
result = client.verify("test@example.com")
assert result.v is True
client.close()

asyncio.run(_inner())
4 changes: 3 additions & 1 deletion validkit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""
ValidKit Python SDK
Async email verification for AI agents and high-volume applications
Email verification for AI agents and high-volume applications
"""

from .sync_client import ValidKit
from .client import AsyncValidKit
from .config import ValidKitConfig
from .models import (
Expand Down Expand Up @@ -31,6 +32,7 @@

__all__ = [
# Client
"ValidKit",
"AsyncValidKit",
"ValidKitConfig",

Expand Down
2 changes: 1 addition & 1 deletion validkit/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Single source of truth for the SDK version."""

__version__ = "1.1.3"
__version__ = "1.2.0"
Loading