diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..592a611
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,28 @@
+name: Tests
+
+on:
+ push:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ version: "0.9.5"
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.9"
+
+ - name: Install dependencies
+ run: uv sync --group dev
+
+ - name: Run tests
+ run: uv run pytest tests/ -v
+
diff --git a/.speakeasy/gen.yaml b/.speakeasy/gen.yaml
index 030f40c..9791cea 100644
--- a/.speakeasy/gen.yaml
+++ b/.speakeasy/gen.yaml
@@ -30,7 +30,10 @@ generation:
python:
version: 0.2.0
additionalDependencies:
- dev: {}
+ dev:
+ pytest: ">=8.0.0"
+ respx: ">=0.21.0"
+ inline-snapshot: ">=0.13.0"
main: {}
allowedRedefinedBuiltins:
- id
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..ec15ab9
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,15 @@
+# Testing Setup
+
+Tests use pytest with `respx` for HTTP mocking and `inline-snapshot` for assertions.
+
+**MockContext helper** (`tests/conftest.py`):
+- `ctx = MockContext(api_key="...", integration_id="...")` - creates SDK instance
+- `ctx.mock_endpoint(method, path, response)` - mocks HTTP endpoints
+- `ctx.get_last_request()` - captures request details (path, headers, body)
+- `ctx.clear()` - resets mocks between calls
+
+**Running tests**:
+- `mise run test` or `uv run pytest tests/ -v`
+- `mise run test:update` - update snapshots
+- Use `snapshot(...)` from `inline-snapshot` for assertions
+
diff --git a/mise.toml b/mise.toml
index 0474256..2db8c12 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,6 +1,16 @@
[tools]
+python = "3.9"
"aqua:speakeasy-api/speakeasy" = "1"
+uv = "0.9.5"
[tasks."dev"]
description = "Build the SDK without publishing"
run = 'OPENAPI_DOC_AUTH_TOKEN="$(doppler secrets get SDK_SPEC_ENDPOINT_TOKEN --project kombo-engine --config dev --plain)" speakeasy run --minimal --skip-upload-spec --skip-versioning --skip-compile --target kombo-python'
+
+[tasks."test"]
+description = "Run tests"
+run = "uv run pytest tests/ -v"
+
+[tasks."test:update"]
+description = "Run tests and update inline snapshots"
+run = "uv run pytest tests/ --inline-snapshot=fix -v"
diff --git a/pyproject.toml b/pyproject.toml
index 348fd38..c15a633 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,9 @@ dev = [
"mypy ==1.15.0",
"pylint ==3.2.3",
"pyright ==1.1.398",
+ "pytest >=8.0.0",
+ "respx >=0.21.0",
+ "inline-snapshot >=0.13.0",
]
[tool.setuptools.packages.find]
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..bedcb58
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+"""Test suite for Kombo Python SDK."""
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..5f59e49
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,176 @@
+"""Test fixtures and helpers for Kombo SDK tests."""
+
+from typing import Optional, Dict, Any, List
+import json
+import re
+import pytest
+import respx
+import httpx
+from kombo import Kombo
+
+
+# Sentinel value to distinguish between "not provided" and "explicitly None"
+_UNSET = object()
+
+
+class CapturedRequest:
+ """Represents a captured HTTP request."""
+
+ def __init__(self, method: str, path: str, headers: Dict[str, str], body: Optional[Any] = None):
+ self.method = method
+ self.path = path
+ self.headers = headers
+ self.body = body
+
+
+class MockContext:
+ """Test context for mocking HTTP requests and capturing request details."""
+
+ def __init__(self, api_key: Optional[str] = None, integration_id: Any = _UNSET):
+ """
+ Initialize test context.
+
+ :param api_key: API key to use (defaults to "test-api-key")
+ :param integration_id: Integration ID to use. Defaults to "test-integration-id" if not provided.
+ Pass None explicitly to omit the integration_id from SDK initialization.
+ """
+ self._api_key = api_key or "test-api-key"
+
+ # Determine integration_id value
+ if integration_id is _UNSET:
+ # Not provided, use default
+ id_to_use = "test-integration-id"
+ else:
+ # Use provided value (could be None, a string, etc.)
+ id_to_use = integration_id
+
+ self._integration_id = id_to_use
+ self._captured_requests: List[CapturedRequest] = []
+
+ # Initialize SDK
+ if id_to_use is None:
+ self.kombo = Kombo(api_key=self._api_key)
+ else:
+ self.kombo = Kombo(api_key=self._api_key, integration_id=id_to_use)
+
+ def mock_endpoint(
+ self,
+ method: str,
+ path: str,
+ response: Dict[str, Any],
+ ) -> None:
+ """
+ Mock an HTTP endpoint.
+
+ :param method: HTTP method (GET, POST, PUT, DELETE, PATCH)
+ :param path: URL path (e.g., "/v1/ats/jobs")
+ :param response: Response dict with 'body', optional 'statusCode', and optional 'headers'
+ """
+ status_code = response.get("statusCode", 200)
+ body = response.get("body")
+ response_headers = response.get("headers", {})
+
+ # Set up Content-Type header for JSON responses if not provided
+ if isinstance(body, dict) and "Content-Type" not in response_headers:
+ response_headers = {**response_headers, "Content-Type": "application/json"}
+
+ # Prepare response content
+ if isinstance(body, (dict, list)):
+ content = json.dumps(body).encode()
+ elif isinstance(body, str):
+ content = body.encode()
+ else:
+ content = b""
+
+ # Create response function that captures request
+ def create_response(request: httpx.Request) -> httpx.Response:
+ # Capture request details
+ query_string = request.url.query.decode() if request.url.query else ""
+ full_path = request.url.path + (f"?{query_string}" if query_string else "")
+
+ # Read body for non-GET requests
+ request_body = None
+ if request.method != "GET":
+ try:
+ # Try to get content from request
+ if hasattr(request, "_content"):
+ body_bytes = request._content
+ elif hasattr(request, "content"):
+ body_bytes = request.content
+ else:
+ # Try reading from stream
+ body_bytes = request.read()
+
+ if body_bytes:
+ try:
+ if isinstance(body_bytes, bytes):
+ request_body = json.loads(body_bytes.decode())
+ else:
+ request_body = json.loads(body_bytes)
+ except (json.JSONDecodeError, UnicodeDecodeError, TypeError):
+ if isinstance(body_bytes, bytes):
+ request_body = body_bytes.decode()
+ else:
+ request_body = body_bytes
+ except Exception:
+ # If we can't read the body, that's okay
+ pass
+
+ captured = CapturedRequest(
+ method=request.method,
+ path=full_path,
+ headers=dict(request.headers),
+ body=request_body,
+ )
+ self._captured_requests.append(captured)
+
+ return httpx.Response(
+ status_code=status_code,
+ headers=response_headers,
+ content=content,
+ )
+
+ # Create the mock route
+ # For GET requests, match any query parameters using regex
+ # For other methods, match exact path
+ base_url = f"https://api.kombo.dev{path}"
+ if method == "GET":
+ # Match the base path, allowing any query parameters
+ route = respx.request(
+ method=method,
+ url__regex=re.compile(f"^{re.escape(base_url)}(\\?.*)?$"),
+ )
+ else:
+ route = respx.request(
+ method=method,
+ url=base_url,
+ )
+
+ route.mock(side_effect=create_response)
+
+ def get_requests(self) -> List[CapturedRequest]:
+ """Get all captured requests."""
+ return list(self._captured_requests)
+
+ def get_last_request(self) -> CapturedRequest:
+ """Get the last captured request."""
+ if not self._captured_requests:
+ raise RuntimeError("No requests captured!")
+ return self._captured_requests[-1]
+
+ def clear(self) -> None:
+ """Clear captured requests and reset mocks."""
+ self._captured_requests.clear()
+ respx.stop()
+ respx.clear()
+ respx.start() # Restart respx so new mocks can be registered
+
+
+@pytest.fixture(autouse=True)
+def reset_respx():
+ """Reset respx mocks before and after each test."""
+ respx.start()
+ yield
+ respx.stop()
+ respx.clear()
+
diff --git a/tests/test_basic_behavior.py b/tests/test_basic_behavior.py
new file mode 100644
index 0000000..f4d45e8
--- /dev/null
+++ b/tests/test_basic_behavior.py
@@ -0,0 +1,159 @@
+"""Tests for basic SDK behavior."""
+
+import pytest
+from inline_snapshot import snapshot
+from tests.conftest import MockContext
+
+
+class TestBasicSDKBehavior:
+ """Test basic SDK behavior."""
+
+ def test_should_include_api_key_in_authorization_header(self):
+ """Test that API key is included in Authorization header."""
+ ctx = MockContext(api_key="my-custom-api-key")
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/ats/jobs",
+ response={
+ "body": {
+ "status": "success",
+ "data": {"results": [], "next": None},
+ },
+ },
+ )
+
+ jobs = ctx.kombo.ats.get_jobs()
+ if jobs is not None:
+ _ = jobs.next() # Consume first page
+
+ request = ctx.get_last_request()
+ assert request.headers.get("authorization") == snapshot("Bearer my-custom-api-key")
+
+ def test_should_include_integration_id_in_x_integration_id_header_when_specified(self):
+ """Test that X-Integration-Id header is included when specified."""
+ ctx = MockContext(
+ api_key="test-key",
+ integration_id="my-integration-123",
+ )
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/ats/jobs",
+ response={
+ "body": {
+ "status": "success",
+ "data": {"results": [], "next": None},
+ },
+ },
+ )
+
+ jobs = ctx.kombo.ats.get_jobs()
+ if jobs is not None:
+ _ = jobs.next() # Consume first page
+
+ request = ctx.get_last_request()
+ assert request.headers.get("x-integration-id") == snapshot("my-integration-123")
+
+ def test_should_not_include_x_integration_id_header_when_not_provided(self):
+ """Test that X-Integration-Id header is not included when not provided."""
+ ctx = MockContext(
+ api_key="test-key",
+ integration_id=None,
+ )
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/ats/jobs",
+ response={
+ "body": {
+ "status": "success",
+ "data": {"results": [], "next": None},
+ },
+ },
+ )
+
+ jobs = ctx.kombo.ats.get_jobs()
+ if jobs is not None:
+ _ = jobs.next() # Consume first page
+
+ request = ctx.get_last_request()
+ # When integration ID is None, the header should not be set
+ assert request.headers.get("x-integration-id") is None
+
+ def test_should_correctly_encode_comma_separated_query_parameters(self):
+ """Test that comma-separated query parameters are correctly encoded."""
+ ctx = MockContext()
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/ats/jobs",
+ response={
+ "body": {
+ "status": "success",
+ "data": {
+ "results": [],
+ "next": None,
+ },
+ },
+ },
+ )
+
+ # Make the API call
+ jobs = ctx.kombo.ats.get_jobs(
+ statuses=["OPEN", "CLOSED"],
+ ids=["CPDifhHr7izJhKHmGPkXqknC", "J7znt8TJRiwPVA7paC2iCh8u"],
+ )
+ if jobs is not None:
+ _ = jobs.next() # Consume first page
+
+ # Verify and snapshot the request details
+ request = ctx.get_last_request()
+ assert request.path == snapshot(
+ '/v1/ats/jobs?page_size=100&include_deleted=false&ids=CPDifhHr7izJhKHmGPkXqknC%2CJ7znt8TJRiwPVA7paC2iCh8u&statuses=OPEN%2CCLOSED'
+ )
+
+ def test_should_correctly_encode_boolean_query_parameters(self):
+ """Test that boolean query parameters are correctly encoded."""
+ ctx = MockContext()
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/ats/jobs",
+ response={
+ "body": {
+ "status": "success",
+ "data": {"results": [], "next": None},
+ },
+ },
+ )
+
+ # Test with boolean true
+ jobs_with_deleted = ctx.kombo.ats.get_jobs(include_deleted=True)
+ if jobs_with_deleted is not None:
+ _ = jobs_with_deleted.next() # Consume first page
+
+ request_with_deleted = ctx.get_last_request()
+ assert "include_deleted=true" in request_with_deleted.path
+
+ ctx.clear()
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/ats/jobs",
+ response={
+ "body": {
+ "status": "success",
+ "data": {"results": [], "next": None},
+ },
+ },
+ )
+
+ # Test with boolean false
+ jobs_without_deleted = ctx.kombo.ats.get_jobs(include_deleted=False)
+ if jobs_without_deleted is not None:
+ _ = jobs_without_deleted.next() # Consume first page
+
+ request_without_deleted = ctx.get_last_request()
+ assert "include_deleted=false" in request_without_deleted.path
+
diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py
new file mode 100644
index 0000000..55121ce
--- /dev/null
+++ b/tests/test_error_handling.py
@@ -0,0 +1,405 @@
+"""Tests for error handling."""
+
+import pytest
+from inline_snapshot import snapshot
+from tests.conftest import MockContext
+from kombo.errors import (
+ KomboAtsError,
+ KomboHrisError,
+ KomboGeneralError,
+ SDKDefaultError,
+ ResponseValidationError,
+)
+
+
+class TestErrorHandling:
+ """Test error handling behavior."""
+
+ class TestATSEndpoints:
+ """Test ATS endpoint error handling."""
+
+ def test_returns_kombo_ats_error_for_platform_rate_limit_errors(self):
+ """Test that KomboAtsError is returned for platform rate limit errors."""
+ ctx = MockContext()
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/ats/jobs",
+ response={
+ "statusCode": 429,
+ "body": {
+ "status": "error",
+ "error": {
+ "code": "PLATFORM.RATE_LIMIT_EXCEEDED",
+ "title": "Rate limit exceeded",
+ "message": "You have exceeded the rate limit. Please try again later.",
+ "log_url": "https://app.kombo.dev/logs/abc123",
+ },
+ },
+ },
+ )
+
+ with pytest.raises(KomboAtsError) as exc_info:
+ jobs = ctx.kombo.ats.get_jobs()
+ if jobs is not None:
+ _ = jobs.next() # Consume first page
+
+ error = exc_info.value
+ assert str(error) == snapshot("You have exceeded the rate limit. Please try again later.")
+ assert isinstance(error, KomboAtsError)
+
+ assert error.data.error.code == snapshot("PLATFORM.RATE_LIMIT_EXCEEDED")
+ assert error.data.error.title == snapshot("Rate limit exceeded")
+ assert error.data.error.message == snapshot("You have exceeded the rate limit. Please try again later.")
+ assert error.data.error.log_url == snapshot("https://app.kombo.dev/logs/abc123")
+ assert error.data.status == snapshot("error")
+
+ def test_returns_kombo_ats_error_for_ats_specific_job_closed_errors(self):
+ """Test that KomboAtsError is returned for ATS-specific job closed errors."""
+ ctx = MockContext()
+
+ ctx.mock_endpoint(
+ method="POST",
+ path="/v1/ats/jobs/test-job-id/applications",
+ response={
+ "statusCode": 400,
+ "body": {
+ "status": "error",
+ "error": {
+ "code": "ATS.JOB_CLOSED",
+ "title": "Job is closed",
+ "message": "Cannot create application for a closed job. The job must be in an open state.",
+ "log_url": "https://app.kombo.dev/logs/ghi789",
+ },
+ },
+ },
+ )
+
+ with pytest.raises(KomboAtsError) as exc_info:
+ ctx.kombo.ats.create_application(
+ job_id="test-job-id",
+ candidate={
+ "first_name": "John",
+ "last_name": "Doe",
+ "email_address": "john.doe@example.com",
+ },
+ )
+
+ error = exc_info.value
+ assert str(error) == snapshot(
+ "Cannot create application for a closed job. The job must be in an open state."
+ )
+ assert isinstance(error, KomboAtsError)
+
+ assert error.data.error.code == snapshot("ATS.JOB_CLOSED")
+ assert error.data.error.title == snapshot("Job is closed")
+ assert error.data.error.message == snapshot(
+ "Cannot create application for a closed job. The job must be in an open state."
+ )
+ assert error.data.error.log_url == snapshot("https://app.kombo.dev/logs/ghi789")
+ assert error.data.status == snapshot("error")
+
+ class TestHRISEndpoints:
+ """Test HRIS endpoint error handling."""
+
+ def test_returns_kombo_hris_error_for_integration_permission_errors(self):
+ """Test that KomboHrisError is returned for integration permission errors."""
+ ctx = MockContext()
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/hris/employees",
+ response={
+ "statusCode": 403,
+ "body": {
+ "status": "error",
+ "error": {
+ "code": "INTEGRATION.PERMISSION_MISSING",
+ "title": "Permission missing",
+ "message": "The integration is missing required permissions to access this resource.",
+ "log_url": "https://app.kombo.dev/logs/hris-def456",
+ },
+ },
+ },
+ )
+
+ with pytest.raises(KomboHrisError) as exc_info:
+ employees = ctx.kombo.hris.get_employees()
+ if employees is not None:
+ _ = employees.next() # Consume first page
+
+ error = exc_info.value
+ assert str(error) == snapshot(
+ "The integration is missing required permissions to access this resource."
+ )
+ assert isinstance(error, KomboHrisError)
+
+ assert error.data.error.code == snapshot("INTEGRATION.PERMISSION_MISSING")
+ assert error.data.error.title == snapshot("Permission missing")
+ assert error.data.error.message == snapshot(
+ "The integration is missing required permissions to access this resource."
+ )
+ assert error.data.error.log_url == snapshot("https://app.kombo.dev/logs/hris-def456")
+ assert error.data.status == snapshot("error")
+
+ class TestAssessmentEndpoints:
+ """Test Assessment endpoint error handling."""
+
+ def test_returns_kombo_ats_error_for_platform_input_validation_errors(self):
+ """Test that KomboAtsError is returned for platform input validation errors."""
+ ctx = MockContext()
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/assessment/orders/open",
+ response={
+ "statusCode": 400,
+ "body": {
+ "status": "error",
+ "error": {
+ "code": "PLATFORM.INPUT_INVALID",
+ "title": "Input invalid",
+ "message": "The provided input is invalid or malformed.",
+ "log_url": "https://app.kombo.dev/logs/assessment-xyz",
+ },
+ },
+ },
+ )
+
+ with pytest.raises(KomboAtsError) as exc_info:
+ orders = ctx.kombo.assessment.get_open_orders()
+ if orders is not None:
+ _ = orders.next() # Consume first page
+
+ error = exc_info.value
+ # Assessment uses KomboAtsError for errors
+ assert str(error) == snapshot("The provided input is invalid or malformed.")
+ assert isinstance(error, KomboAtsError)
+
+ assert error.data.error.code == snapshot("PLATFORM.INPUT_INVALID")
+ assert error.data.error.title == snapshot("Input invalid")
+ assert error.data.error.message == snapshot("The provided input is invalid or malformed.")
+ assert error.data.error.log_url == snapshot("https://app.kombo.dev/logs/assessment-xyz")
+ assert error.data.status == snapshot("error")
+
+ class TestGeneralEndpoints:
+ """Test General endpoint error handling."""
+
+ def test_returns_kombo_general_error_for_authentication_errors(self):
+ """Test that KomboGeneralError is returned for authentication errors."""
+ ctx = MockContext()
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/check-api-key",
+ response={
+ "statusCode": 401,
+ "body": {
+ "status": "error",
+ "error": {
+ "code": "PLATFORM.AUTHENTICATION_INVALID",
+ "title": "Authentication invalid",
+ "message": "The provided API key is invalid or expired.",
+ "log_url": "https://app.kombo.dev/logs/general-auth-123",
+ },
+ },
+ },
+ )
+
+ with pytest.raises(KomboGeneralError) as exc_info:
+ ctx.kombo.general.check_api_key()
+
+ error = exc_info.value
+ # General endpoints use KomboGeneralError for errors
+ assert str(error) == snapshot("The provided API key is invalid or expired.")
+ assert isinstance(error, KomboGeneralError)
+
+ assert error.data.error.code == snapshot("PLATFORM.AUTHENTICATION_INVALID")
+ assert error.data.error.title == snapshot("Authentication invalid")
+ assert error.data.error.message == snapshot("The provided API key is invalid or expired.")
+ assert error.data.error.log_url == snapshot("https://app.kombo.dev/logs/general-auth-123")
+ assert error.data.status == snapshot("error")
+
+ class TestUnexpectedResponseFormats:
+ """Test handling of unexpected response formats."""
+
+ class TestSDKDefaultErrorForNonJSONResponses:
+ """Test SDKDefaultError thrown for non-JSON responses."""
+
+ def test_handles_plain_text_500_error_from_load_balancer(self):
+ """Test handling of plain text 500 error from load balancer."""
+ ctx = MockContext()
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/ats/jobs",
+ response={
+ "statusCode": 500,
+ "body": "500 Internal Server Error",
+ },
+ )
+
+ with pytest.raises(SDKDefaultError) as exc_info:
+ jobs = ctx.kombo.ats.get_jobs()
+ if jobs is not None:
+ _ = jobs.next() # Consume first page
+
+ error = exc_info.value
+ assert isinstance(error, SDKDefaultError)
+ assert str(error) == snapshot(
+ 'Unexpected response received: Status 500 Content-Type "". Body: 500 Internal Server Error'
+ )
+
+ def test_handles_plain_text_502_bad_gateway_error(self):
+ """Test handling of plain text 502 bad gateway error."""
+ ctx = MockContext()
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/hris/employees",
+ response={
+ "statusCode": 502,
+ "body": "502 Bad Gateway",
+ "headers": {
+ "Content-Type": "text/plain",
+ },
+ },
+ )
+
+ with pytest.raises(SDKDefaultError) as exc_info:
+ employees = ctx.kombo.hris.get_employees()
+ if employees is not None:
+ _ = employees.next() # Consume first page
+
+ error = exc_info.value
+ assert isinstance(error, SDKDefaultError)
+ assert str(error) == snapshot(
+ 'Unexpected response received: Status 502 Content-Type text/plain. Body: 502 Bad Gateway'
+ )
+
+ def test_handles_html_error_page_from_nginx(self):
+ """Test handling of HTML error page from nginx."""
+ ctx = MockContext()
+
+ html_error_page = """
+
+
503 Service Temporarily Unavailable
+
+503 Service Temporarily Unavailable
+
nginx/1.18.0
+
+"""
+
+ ctx.mock_endpoint(
+ method="POST",
+ path="/v1/ats/jobs/test-job-id/applications",
+ response={
+ "statusCode": 503,
+ "body": html_error_page,
+ },
+ )
+
+ with pytest.raises(SDKDefaultError) as exc_info:
+ ctx.kombo.ats.create_application(
+ job_id="test-job-id",
+ candidate={
+ "first_name": "John",
+ "last_name": "Doe",
+ "email_address": "john.doe@example.com",
+ },
+ )
+
+ error = exc_info.value
+ assert isinstance(error, SDKDefaultError)
+ assert str(error) == snapshot(
+ """\
+Unexpected response received: Status 503 Content-Type "". Body:
+
+503 Service Temporarily Unavailable
+
+503 Service Temporarily Unavailable
+
nginx/1.18.0
+
+\
+"""
+ )
+
+ def test_handles_empty_response_body_with_error_status_code(self):
+ """Test handling of empty response body with error status code."""
+ ctx = MockContext()
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/check-api-key",
+ response={
+ "statusCode": 500,
+ "body": "",
+ },
+ )
+
+ with pytest.raises(SDKDefaultError) as exc_info:
+ ctx.kombo.general.check_api_key()
+
+ error = exc_info.value
+ assert isinstance(error, SDKDefaultError)
+ assert str(error) == snapshot('Unexpected response received: Status 500 Content-Type "". Body: ""')
+
+ def test_handles_unexpected_content_type_header(self):
+ """Test handling of unexpected Content-Type header."""
+ ctx = MockContext()
+
+ # Response with unexpected Content-Type
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/ats/applications",
+ response={
+ "statusCode": 500,
+ "body": "Server error occurred",
+ "headers": {
+ "Content-Type": "text/xml",
+ },
+ },
+ )
+
+ with pytest.raises(SDKDefaultError) as exc_info:
+ applications = ctx.kombo.ats.get_applications()
+ if applications is not None:
+ _ = applications.next() # Consume first page
+
+ error = exc_info.value
+ assert isinstance(error, SDKDefaultError)
+ assert str(error) == snapshot(
+ 'Unexpected response received: Status 500 Content-Type text/xml. Body: Server error occurred'
+ )
+
+ def test_handles_unexpected_json_structure_in_error_response(self):
+ """Test handling of unexpected JSON structure in error response (ResponseValidationError)."""
+ ctx = MockContext()
+
+ # Valid JSON but unexpected structure (not matching Kombo error format)
+ unexpected_json = {
+ "errorCode": "500",
+ "errorMessage": "Internal server error",
+ "timestamp": "2024-01-01T00:00:00Z",
+ }
+
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/ats/jobs",
+ response={
+ "statusCode": 500,
+ "body": unexpected_json,
+ },
+ )
+
+ with pytest.raises(ResponseValidationError) as exc_info:
+ jobs = ctx.kombo.ats.get_jobs()
+ if jobs is not None:
+ _ = jobs.next() # Consume first page
+
+ error = exc_info.value
+ # Valid JSON but unexpected structure triggers ResponseValidationError
+ assert isinstance(error, ResponseValidationError)
+ assert "Response validation failed" in str(error)
+
+
diff --git a/tests/test_job_board.py b/tests/test_job_board.py
new file mode 100644
index 0000000..670d685
--- /dev/null
+++ b/tests/test_job_board.py
@@ -0,0 +1,37 @@
+"""Tests for Kombo ATS Jobs API."""
+
+from inline_snapshot import snapshot
+from tests.conftest import MockContext
+
+
+class TestKomboATSJobsAPI:
+ """Test Kombo ATS Jobs API."""
+
+ def test_should_make_correct_http_request_for_get_jobs(self):
+ """Test that getJobs makes correct HTTP request."""
+ ctx = MockContext()
+
+ # Mock the API endpoint
+ ctx.mock_endpoint(
+ method="GET",
+ path="/v1/ats/jobs",
+ response={
+ "body": {
+ "status": "success",
+ "data": {
+ "results": [],
+ "next": None,
+ },
+ },
+ },
+ )
+
+ # Make the API call
+ jobs = ctx.kombo.ats.get_jobs()
+ if jobs is not None:
+ _ = jobs.next() # Consume first page
+
+ # Verify and snapshot the request details
+ request = ctx.get_last_request()
+ assert request.path == snapshot('/v1/ats/jobs?page_size=100&include_deleted=false')
+
diff --git a/uv.lock b/uv.lock
index adc6c2b..8d0c4ab 100644
--- a/uv.lock
+++ b/uv.lock
@@ -44,6 +44,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/80/96/b32bbbb46170a1c8b8b1f28c794202e25cfe743565e9d3469b8eb1e0cc05/astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25", size = 276348, upload-time = "2024-07-20T12:57:40.886Z" },
]
+[[package]]
+name = "asttokens"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
+]
+
[[package]]
name = "certifi"
version = "2025.11.12"
@@ -83,6 +92,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
]
+[[package]]
+name = "executing"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -129,6 +147,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
+[[package]]
+name = "iniconfig"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version == '3.11.*'",
+ "python_full_version == '3.10.*'",
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "inline-snapshot"
+version = "0.31.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asttokens" },
+ { name = "executing" },
+ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "rich" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1c/b1/52b5ee59f73ed31d5fe21b10881bf2d121d07d54b23c0b6b74186792e620/inline_snapshot-0.31.1.tar.gz", hash = "sha256:4ea5ed70aa1d652713bbfd750606b94bd8a42483f7d3680433b3e92994495f64", size = 2606338, upload-time = "2025-11-07T07:36:18.932Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/52/945db420380efbda8c69a7a4a16c53df9d7ac50d8217286b9d41e5d825ff/inline_snapshot-0.31.1-py3-none-any.whl", hash = "sha256:7875a73c986a03388c7e758fb5cb8a43d2c3a20328aa1d851bfb4ed536c4496f", size = 71965, upload-time = "2025-11-07T07:36:16.836Z" },
+]
+
[[package]]
name = "isort"
version = "5.13.2"
@@ -160,9 +221,13 @@ dependencies = [
[package.dev-dependencies]
dev = [
+ { name = "inline-snapshot" },
{ name = "mypy" },
{ name = "pylint" },
{ name = "pyright" },
+ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "respx" },
]
[package.metadata]
@@ -175,9 +240,44 @@ requires-dist = [
[package.metadata.requires-dev]
dev = [
+ { name = "inline-snapshot", specifier = ">=0.13.0" },
{ name = "mypy", specifier = "==1.15.0" },
{ name = "pylint", specifier = "==3.2.3" },
{ name = "pyright", specifier = "==1.1.398" },
+ { name = "pytest", specifier = ">=8.0.0" },
+ { name = "respx", specifier = ">=0.21.0" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "3.0.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
+dependencies = [
+ { name = "mdurl", marker = "python_full_version < '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version == '3.11.*'",
+ "python_full_version == '3.10.*'",
+]
+dependencies = [
+ { name = "mdurl", marker = "python_full_version >= '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
@@ -189,6 +289,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
]
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
[[package]]
name = "mypy"
version = "1.15.0"
@@ -251,6 +360,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
]
+[[package]]
+name = "packaging"
+version = "25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
+]
+
[[package]]
name = "platformdirs"
version = "4.4.0"
@@ -277,6 +395,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
]
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
[[package]]
name = "pydantic"
version = "2.12.4"
@@ -423,6 +550,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
]
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+]
+
[[package]]
name = "pylint"
version = "3.2.3"
@@ -457,6 +593,76 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/e0/5283593f61b3c525d6d7e94cfb6b3ded20b3df66e953acaf7bb4f23b3f6e/pyright-1.1.398-py3-none-any.whl", hash = "sha256:0a70bfd007d9ea7de1cf9740e1ad1a40a122592cfe22a3f6791b06162ad08753", size = 5780235, upload-time = "2025-03-26T10:06:03.994Z" },
]
+[[package]]
+name = "pytest"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
+dependencies = [
+ { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.10'" },
+ { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "packaging", marker = "python_full_version < '3.10'" },
+ { name = "pluggy", marker = "python_full_version < '3.10'" },
+ { name = "pygments", marker = "python_full_version < '3.10'" },
+ { name = "tomli", marker = "python_full_version < '3.10'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.12'",
+ "python_full_version == '3.11.*'",
+ "python_full_version == '3.10.*'",
+]
+dependencies = [
+ { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
+ { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "packaging", marker = "python_full_version >= '3.10'" },
+ { name = "pluggy", marker = "python_full_version >= '3.10'" },
+ { name = "pygments", marker = "python_full_version >= '3.10'" },
+ { name = "tomli", marker = "python_full_version == '3.10.*'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+]
+
+[[package]]
+name = "respx"
+version = "0.22.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f4/7c/96bd0bc759cf009675ad1ee1f96535edcb11e9666b985717eb8c87192a95/respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91", size = 28439, upload-time = "2024-12-19T22:33:59.374Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" },
+]
+
+[[package]]
+name = "rich"
+version = "14.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
+]
+
[[package]]
name = "sniffio"
version = "1.3.1"