diff --git a/README.md b/README.md index b4da93e..70b4e65 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/setup.cfg b/setup.cfg index 49442af..4b92515 100644 --- a/setup.cfg +++ b/setup.cfg @@ -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 diff --git a/setup.py b/setup.py index 5a2cd1d..2c05c29 100644 --- a/setup.py +++ b/setup.py @@ -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", diff --git a/tests/conftest.py b/tests/conftest.py index 73b0d35..5340b68 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 @@ -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.""" diff --git a/tests/test_config.py b/tests/test_config.py index 81e4fef..061fe3b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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" diff --git a/tests/test_imports.py b/tests/test_imports.py index 0d84b03..4474269 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -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 @@ -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(): diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py new file mode 100644 index 0000000..84a5c15 --- /dev/null +++ b/tests/test_sync_client.py @@ -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()) diff --git a/validkit/__init__.py b/validkit/__init__.py index 2c21b2a..f254070 100644 --- a/validkit/__init__.py +++ b/validkit/__init__.py @@ -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 ( @@ -31,6 +32,7 @@ __all__ = [ # Client + "ValidKit", "AsyncValidKit", "ValidKitConfig", diff --git a/validkit/_version.py b/validkit/_version.py index 9572cf6..fd7ee9f 100644 --- a/validkit/_version.py +++ b/validkit/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the SDK version.""" -__version__ = "1.1.3" +__version__ = "1.2.0" diff --git a/validkit/sync_client.py b/validkit/sync_client.py new file mode 100644 index 0000000..016cdfd --- /dev/null +++ b/validkit/sync_client.py @@ -0,0 +1,161 @@ +"""Synchronous client for ValidKit API. + +Wraps AsyncValidKit in a dedicated background event loop so callers +never need to write ``async`` / ``await``. Safe to use inside Jupyter +notebooks, Django views, and other contexts where an event loop may +already be running. +""" + +import asyncio +import threading +from typing import Optional, Dict, List, Union, Callable + +from .client import AsyncValidKit +from .config import ValidKitConfig +from .models import ( + EmailVerificationResult, + BatchVerificationResult, + BatchJob, + CompactResult, + ResponseFormat, +) + + +class _SyncRunner: + """Run coroutines synchronously via a background thread's event loop.""" + + def __init__(self) -> None: + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread( + target=self._loop.run_forever, daemon=True + ) + self._thread.start() + + def run(self, coro): + """Submit *coro* to the background loop and block until it resolves.""" + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + return future.result() + + def close(self) -> None: + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join(timeout=5) + + +class ValidKit: + """Synchronous client for ValidKit email verification. + + Usage:: + + from validkit import ValidKit + + client = ValidKit("YOUR_API_KEY") + result = client.verify("user@example.com") + print(result.v) # True / False + client.close() + + Or as a context manager:: + + with ValidKit("YOUR_API_KEY") as client: + result = client.verify("user@example.com") + """ + + def __init__( + self, + api_key: Optional[str] = None, + config: Optional[ValidKitConfig] = None, + ): + self._closed = False + self._runner = _SyncRunner() + self._async_client = AsyncValidKit(api_key=api_key, config=config) + # Eagerly create the aiohttp session inside the background loop + self._runner.run(self._async_client._ensure_session()) + + # -- context manager ------------------------------------------------- + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + # -- public API ------------------------------------------------------ + + def verify( + self, + email: str, + format: ResponseFormat = ResponseFormat.FULL, + ) -> Union[EmailVerificationResult, CompactResult]: + """Verify a single email address. + + Args: + email: Email address to verify. + format: Response format (``FULL`` or ``COMPACT``). + + Returns: + Verification result. + """ + return self._runner.run( + self._async_client.verify_email(email, format=format) + ) + + def verify_batch( + self, + emails: List[str], + format: ResponseFormat = ResponseFormat.COMPACT, + chunk_size: Optional[int] = None, + progress_callback: Optional[Callable[[int, int], None]] = None, + ) -> Dict[str, Union[EmailVerificationResult, CompactResult]]: + """Verify a batch of emails (up to 10 000). + + Args: + emails: List of email addresses. + format: Response format. + chunk_size: Number of emails per chunk (default 1 000). + progress_callback: ``fn(processed, total)`` called after each chunk. + Must be a plain function, not an async coroutine. + + Returns: + Dictionary mapping each email to its result. + """ + if progress_callback and asyncio.iscoroutinefunction(progress_callback): + raise TypeError( + "progress_callback must be a plain function, not async. " + "Use AsyncValidKit for async callbacks." + ) + return self._runner.run( + self._async_client.verify_batch( + emails, + format=format, + chunk_size=chunk_size, + progress_callback=progress_callback, + ) + ) + + def get_batch_status(self, job_id: str) -> BatchJob: + """Get status of an async batch job.""" + return self._runner.run( + self._async_client.get_batch_status(job_id) + ) + + def get_batch_results(self, job_id: str) -> BatchVerificationResult: + """Get results of a completed batch job.""" + return self._runner.run( + self._async_client.get_batch_results(job_id) + ) + + def cancel_batch(self, job_id: str) -> BatchJob: + """Cancel a batch job.""" + return self._runner.run( + self._async_client.cancel_batch(job_id) + ) + + def close(self) -> None: + """Close the client and release resources. + + Safe to call multiple times. + """ + if self._closed: + return + self._closed = True + self._runner.run(self._async_client.close()) + self._runner.close()