ateve 0.1.0__py3-none-any.whl

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.
ateve/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """Official Python SDK for the Ateve Search API."""
2
+
3
+ from ._version import __version__
4
+ from .client import AsyncAteve, Ateve
5
+ from .errors import (
6
+ ApiConnectionError,
7
+ ApiError,
8
+ ApiResponseError,
9
+ AteveError,
10
+ AuthenticationError,
11
+ ConcurrencyLimitError,
12
+ InvalidRequestError,
13
+ PaymentRequiredError,
14
+ RateLimitError,
15
+ ServerError,
16
+ )
17
+ from .models import (
18
+ AnswerBlock,
19
+ Citation,
20
+ ContentFormat,
21
+ ContentOptions,
22
+ ImageItem,
23
+ LocaleSettings,
24
+ QueryInfo,
25
+ SearchRequest,
26
+ SearchResponse,
27
+ SearchResult,
28
+ Usage,
29
+ UsageBreakdown,
30
+ UserLocation,
31
+ VideoItem,
32
+ )
33
+
34
+ __all__ = [
35
+ "AnswerBlock",
36
+ "ApiConnectionError",
37
+ "ApiError",
38
+ "ApiResponseError",
39
+ "AsyncAteve",
40
+ "Ateve",
41
+ "AteveError",
42
+ "AuthenticationError",
43
+ "Citation",
44
+ "ConcurrencyLimitError",
45
+ "ContentFormat",
46
+ "ContentOptions",
47
+ "ImageItem",
48
+ "InvalidRequestError",
49
+ "LocaleSettings",
50
+ "PaymentRequiredError",
51
+ "QueryInfo",
52
+ "RateLimitError",
53
+ "SearchRequest",
54
+ "SearchResponse",
55
+ "SearchResult",
56
+ "ServerError",
57
+ "Usage",
58
+ "UsageBreakdown",
59
+ "UserLocation",
60
+ "VideoItem",
61
+ "__version__",
62
+ ]
ateve/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
ateve/client.py ADDED
@@ -0,0 +1,512 @@
1
+ """Synchronous and asynchronous Ateve Search API clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import threading
8
+ from datetime import datetime, timezone
9
+ from email.utils import parsedate_to_datetime
10
+ from typing import Any, Dict, Optional, Union
11
+ from urllib.parse import urlsplit, urlunsplit
12
+
13
+ import httpx
14
+ from pydantic import ValidationError
15
+
16
+ from ._version import __version__
17
+ from .errors import (
18
+ ApiConnectionError,
19
+ ApiResponseError,
20
+ AuthenticationError,
21
+ ConcurrencyLimitError,
22
+ InvalidRequestError,
23
+ PaymentRequiredError,
24
+ RateLimitError,
25
+ ServerError,
26
+ )
27
+ from .models import (
28
+ ContentOptions,
29
+ LocaleSettings,
30
+ SearchRequest,
31
+ SearchResponse,
32
+ )
33
+
34
+ DEFAULT_BASE_URL = "https://api.ateve.ai"
35
+ DEFAULT_TIMEOUT = 60.0
36
+ DEFAULT_CONNECT_TIMEOUT = 5.0
37
+ DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024
38
+ USER_AGENT = f"ateve-python/{__version__}"
39
+ _BILLING_CODES = {"insufficient_credit", "credit_limit_exceeded", "payment_required"}
40
+
41
+
42
+ class _BodyLimitExceeded(Exception):
43
+ pass
44
+
45
+
46
+ def _require_api_key(api_key: Optional[str]) -> str:
47
+ value = api_key if api_key is not None else os.getenv("ATEVE_API_KEY")
48
+ if value is None or not value.strip():
49
+ raise ValueError("API key missing: pass api_key or set ATEVE_API_KEY")
50
+ value = value.strip()
51
+ if any(character in value for character in ("\r", "\n", "\0")):
52
+ raise ValueError("api_key must not contain CR, LF, or NUL characters")
53
+ return value
54
+
55
+
56
+ def _normalize_base_url(base_url: Optional[str]) -> str:
57
+ value = base_url if base_url is not None else os.getenv("ATEVE_BASE_URL", DEFAULT_BASE_URL)
58
+ value = value.strip().rstrip("/")
59
+ parsed = urlsplit(value)
60
+ if (
61
+ parsed.scheme.lower() not in {"http", "https"}
62
+ or not parsed.hostname
63
+ or parsed.username is not None
64
+ or parsed.password is not None
65
+ or parsed.query
66
+ or parsed.fragment
67
+ ):
68
+ raise ValueError(
69
+ "base_url must be an absolute http(s) URL without user-info, query, or fragment"
70
+ )
71
+ return urlunsplit((parsed.scheme.lower(), parsed.netloc, parsed.path, "", ""))
72
+
73
+
74
+ def _timeout(timeout: float, connect_timeout: float) -> httpx.Timeout:
75
+ if timeout <= 0 or connect_timeout <= 0:
76
+ raise ValueError("timeout and connect_timeout must be positive")
77
+ return httpx.Timeout(timeout, connect=connect_timeout)
78
+
79
+
80
+ def _request_from_args(
81
+ query: Union[str, SearchRequest],
82
+ *,
83
+ limit: Optional[int],
84
+ offset: Optional[int],
85
+ date_range: Optional[str],
86
+ locale: Optional[LocaleSettings],
87
+ include_domains: Optional[list[str]],
88
+ exclude_domains: Optional[list[str]],
89
+ content: Optional[ContentOptions],
90
+ safe_search: Optional[bool],
91
+ ) -> SearchRequest:
92
+ options = (
93
+ limit,
94
+ offset,
95
+ date_range,
96
+ locale,
97
+ include_domains,
98
+ exclude_domains,
99
+ content,
100
+ safe_search,
101
+ )
102
+ if isinstance(query, SearchRequest):
103
+ if any(value is not None for value in options):
104
+ raise TypeError("options cannot be combined with a SearchRequest instance")
105
+ return query
106
+ return SearchRequest(
107
+ query=query,
108
+ limit=limit,
109
+ offset=offset,
110
+ date_range=date_range,
111
+ locale=locale,
112
+ include_domains=include_domains,
113
+ exclude_domains=exclude_domains,
114
+ content=content,
115
+ safe_search=safe_search,
116
+ )
117
+
118
+
119
+ def _parse_retry_after(value: Optional[str]) -> Optional[int]:
120
+ if value is None:
121
+ return None
122
+ try:
123
+ return max(0, int(value.strip()))
124
+ except ValueError:
125
+ pass
126
+ try:
127
+ when = parsedate_to_datetime(value)
128
+ if when.tzinfo is None:
129
+ when = when.replace(tzinfo=timezone.utc)
130
+ return max(0, int((when - datetime.now(timezone.utc)).total_seconds()))
131
+ except (TypeError, ValueError, OverflowError):
132
+ return None
133
+
134
+
135
+ def _read_json_object(body: bytes) -> Dict[str, Any]:
136
+ try:
137
+ value = json.loads(body)
138
+ except (UnicodeDecodeError, json.JSONDecodeError):
139
+ return {}
140
+ return value if isinstance(value, dict) else {}
141
+
142
+
143
+ def _map_api_error(response: httpx.Response, body: bytes) -> Exception:
144
+ payload = _read_json_object(body)
145
+ error = payload.get("error")
146
+ error = error if isinstance(error, dict) else {}
147
+ raw_request_id = payload.get("id") or response.headers.get("X-Request-Id")
148
+ status = response.status_code
149
+ raw_message = error.get("message")
150
+ message = raw_message if isinstance(raw_message, str) else f"HTTP {status}"
151
+ request_id = raw_request_id if isinstance(raw_request_id, str) else None
152
+ raw_code = error.get("code")
153
+ code = raw_code if isinstance(raw_code, str) else None
154
+ raw_param = error.get("param")
155
+ param = raw_param if isinstance(raw_param, str) else None
156
+ raw_type = error.get("type")
157
+ error_type = raw_type if isinstance(raw_type, str) else None
158
+ raw_doc_url = error.get("doc_url")
159
+ doc_url = raw_doc_url if isinstance(raw_doc_url, str) else None
160
+ if status == 401:
161
+ return AuthenticationError(
162
+ message,
163
+ status_code=status,
164
+ request_id=request_id,
165
+ code=code,
166
+ param=param,
167
+ error_type=error_type,
168
+ doc_url=doc_url,
169
+ )
170
+ if status == 402 or (status == 403 and code in _BILLING_CODES):
171
+ return PaymentRequiredError(
172
+ message,
173
+ status_code=status,
174
+ request_id=request_id,
175
+ code=code,
176
+ param=param,
177
+ error_type=error_type,
178
+ doc_url=doc_url,
179
+ )
180
+ if status == 429:
181
+ return RateLimitError(
182
+ message,
183
+ status_code=status,
184
+ request_id=request_id,
185
+ code=code,
186
+ param=param,
187
+ error_type=error_type,
188
+ doc_url=doc_url,
189
+ retry_after_seconds=_parse_retry_after(response.headers.get("Retry-After")),
190
+ limit_scope=response.headers.get("X-Ateve-Limit-Scope"),
191
+ )
192
+ if status >= 500:
193
+ return ServerError(
194
+ message,
195
+ status_code=status,
196
+ request_id=request_id,
197
+ code=code,
198
+ param=param,
199
+ error_type=error_type,
200
+ doc_url=doc_url,
201
+ )
202
+ return InvalidRequestError(
203
+ message,
204
+ status_code=status,
205
+ request_id=request_id,
206
+ code=code,
207
+ param=param,
208
+ error_type=error_type,
209
+ doc_url=doc_url,
210
+ )
211
+
212
+
213
+ def _request_may_have_been_sent(error: httpx.HTTPError) -> bool:
214
+ return not isinstance(
215
+ error,
216
+ (
217
+ httpx.ConnectError,
218
+ httpx.ConnectTimeout,
219
+ httpx.PoolTimeout,
220
+ httpx.InvalidURL,
221
+ httpx.UnsupportedProtocol,
222
+ ),
223
+ )
224
+
225
+
226
+ def _validate_limits(max_response_bytes: int, max_concurrent_requests: int) -> None:
227
+ if max_response_bytes < 0:
228
+ raise ValueError("max_response_bytes must be >= 0")
229
+ if max_concurrent_requests < 0:
230
+ raise ValueError("max_concurrent_requests must be >= 0")
231
+
232
+
233
+ class _BaseAteve:
234
+ def __init__(
235
+ self,
236
+ *,
237
+ api_key: Optional[str],
238
+ base_url: Optional[str],
239
+ timeout: float,
240
+ connect_timeout: float,
241
+ max_response_bytes: int,
242
+ max_concurrent_requests: int,
243
+ ) -> None:
244
+ _validate_limits(max_response_bytes, max_concurrent_requests)
245
+ self._base_url = _normalize_base_url(base_url)
246
+ self._search_url = self._base_url + "/v1/search"
247
+ self._timeout = _timeout(timeout, connect_timeout)
248
+ self._max_response_bytes = max_response_bytes
249
+ self._max_concurrent_requests = max_concurrent_requests
250
+ self._headers = {
251
+ "Authorization": f"Bearer {_require_api_key(api_key)}",
252
+ "Accept": "application/json",
253
+ "Content-Type": "application/json",
254
+ "User-Agent": USER_AGENT,
255
+ }
256
+ self._closed = False
257
+
258
+ def _ensure_open(self) -> None:
259
+ if self._closed:
260
+ raise RuntimeError("Ateve client is closed")
261
+
262
+ def _decode(self, response: httpx.Response, body: bytes) -> SearchResponse:
263
+ if not 200 <= response.status_code < 300:
264
+ raise _map_api_error(response, body)
265
+ try:
266
+ return SearchResponse.model_validate_json(body)
267
+ except ValidationError as exc:
268
+ raise ApiResponseError(
269
+ f"Response from {self._base_url} could not be decoded as SearchResponse",
270
+ status_code=response.status_code,
271
+ request_id=response.headers.get("X-Request-Id"),
272
+ base_url=self._base_url,
273
+ ) from exc
274
+
275
+
276
+ class Ateve(_BaseAteve):
277
+ """Reusable synchronous client. Close it when the owning application stops."""
278
+
279
+ def __init__(
280
+ self,
281
+ api_key: Optional[str] = None,
282
+ *,
283
+ base_url: Optional[str] = None,
284
+ timeout: float = DEFAULT_TIMEOUT,
285
+ connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
286
+ max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
287
+ max_concurrent_requests: int = 0,
288
+ http_client: Optional[httpx.Client] = None,
289
+ ) -> None:
290
+ super().__init__(
291
+ api_key=api_key,
292
+ base_url=base_url,
293
+ timeout=timeout,
294
+ connect_timeout=connect_timeout,
295
+ max_response_bytes=max_response_bytes,
296
+ max_concurrent_requests=max_concurrent_requests,
297
+ )
298
+ self._owns_http_client = http_client is None
299
+ self._client = http_client or httpx.Client(timeout=self._timeout, follow_redirects=False)
300
+ self._bulkhead = (
301
+ threading.BoundedSemaphore(max_concurrent_requests)
302
+ if max_concurrent_requests > 0
303
+ else None
304
+ )
305
+
306
+ def search(
307
+ self,
308
+ query: Union[str, SearchRequest],
309
+ *,
310
+ limit: Optional[int] = None,
311
+ offset: Optional[int] = None,
312
+ date_range: Optional[str] = None,
313
+ locale: Optional[LocaleSettings] = None,
314
+ include_domains: Optional[list[str]] = None,
315
+ exclude_domains: Optional[list[str]] = None,
316
+ content: Optional[ContentOptions] = None,
317
+ safe_search: Optional[bool] = None,
318
+ ) -> SearchResponse:
319
+ """Send exactly one search request; no retries or endpoint failover."""
320
+ self._ensure_open()
321
+ request = _request_from_args(
322
+ query,
323
+ limit=limit,
324
+ offset=offset,
325
+ date_range=date_range,
326
+ locale=locale,
327
+ include_domains=include_domains,
328
+ exclude_domains=exclude_domains,
329
+ content=content,
330
+ safe_search=safe_search,
331
+ )
332
+ if self._bulkhead is not None and not self._bulkhead.acquire(blocking=False):
333
+ raise ConcurrencyLimitError(self._max_concurrent_requests)
334
+ try:
335
+ try:
336
+ with self._client.stream(
337
+ "POST",
338
+ self._search_url,
339
+ headers=self._headers,
340
+ json=request.model_dump(mode="json", exclude_none=True),
341
+ timeout=self._timeout,
342
+ follow_redirects=False,
343
+ ) as response:
344
+ body = self._read_limited(response)
345
+ except _BodyLimitExceeded as exc:
346
+ raise ApiConnectionError(
347
+ f"Response from {self._base_url} exceeded "
348
+ f"max_response_bytes={self._max_response_bytes}",
349
+ base_url=self._base_url,
350
+ request_may_have_been_sent=True,
351
+ ) from exc
352
+ except httpx.HTTPError as exc:
353
+ raise ApiConnectionError(
354
+ f"Request to {self._base_url} failed: {exc.__class__.__name__}",
355
+ base_url=self._base_url,
356
+ request_may_have_been_sent=_request_may_have_been_sent(exc),
357
+ ) from exc
358
+ return self._decode(response, body)
359
+ finally:
360
+ if self._bulkhead is not None:
361
+ self._bulkhead.release()
362
+
363
+ def _read_limited(self, response: httpx.Response) -> bytes:
364
+ declared = response.headers.get("Content-Length")
365
+ if (
366
+ self._max_response_bytes > 0
367
+ and declared is not None
368
+ and declared.isdigit()
369
+ and int(declared) > self._max_response_bytes
370
+ ):
371
+ raise _BodyLimitExceeded
372
+ body = bytearray()
373
+ for chunk in response.iter_bytes():
374
+ if (
375
+ self._max_response_bytes > 0
376
+ and len(chunk) > self._max_response_bytes - len(body)
377
+ ):
378
+ raise _BodyLimitExceeded
379
+ body.extend(chunk)
380
+ return bytes(body)
381
+
382
+ def close(self) -> None:
383
+ if not self._closed and self._owns_http_client:
384
+ self._client.close()
385
+ self._closed = True
386
+
387
+ def __enter__(self) -> Ateve:
388
+ self._ensure_open()
389
+ return self
390
+
391
+ def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
392
+ self.close()
393
+
394
+
395
+ class AsyncAteve(_BaseAteve):
396
+ """Reusable asynchronous client for high-concurrency applications."""
397
+
398
+ def __init__(
399
+ self,
400
+ api_key: Optional[str] = None,
401
+ *,
402
+ base_url: Optional[str] = None,
403
+ timeout: float = DEFAULT_TIMEOUT,
404
+ connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
405
+ max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
406
+ max_concurrent_requests: int = 0,
407
+ http_client: Optional[httpx.AsyncClient] = None,
408
+ ) -> None:
409
+ super().__init__(
410
+ api_key=api_key,
411
+ base_url=base_url,
412
+ timeout=timeout,
413
+ connect_timeout=connect_timeout,
414
+ max_response_bytes=max_response_bytes,
415
+ max_concurrent_requests=max_concurrent_requests,
416
+ )
417
+ self._owns_http_client = http_client is None
418
+ self._client = http_client or httpx.AsyncClient(
419
+ timeout=self._timeout, follow_redirects=False
420
+ )
421
+ self._in_flight = 0
422
+
423
+ async def search(
424
+ self,
425
+ query: Union[str, SearchRequest],
426
+ *,
427
+ limit: Optional[int] = None,
428
+ offset: Optional[int] = None,
429
+ date_range: Optional[str] = None,
430
+ locale: Optional[LocaleSettings] = None,
431
+ include_domains: Optional[list[str]] = None,
432
+ exclude_domains: Optional[list[str]] = None,
433
+ content: Optional[ContentOptions] = None,
434
+ safe_search: Optional[bool] = None,
435
+ ) -> SearchResponse:
436
+ """Send exactly one non-blocking search request."""
437
+ self._ensure_open()
438
+ request = _request_from_args(
439
+ query,
440
+ limit=limit,
441
+ offset=offset,
442
+ date_range=date_range,
443
+ locale=locale,
444
+ include_domains=include_domains,
445
+ exclude_domains=exclude_domains,
446
+ content=content,
447
+ safe_search=safe_search,
448
+ )
449
+ if (
450
+ self._max_concurrent_requests > 0
451
+ and self._in_flight >= self._max_concurrent_requests
452
+ ):
453
+ raise ConcurrencyLimitError(self._max_concurrent_requests)
454
+ self._in_flight += 1
455
+ try:
456
+ try:
457
+ async with self._client.stream(
458
+ "POST",
459
+ self._search_url,
460
+ headers=self._headers,
461
+ json=request.model_dump(mode="json", exclude_none=True),
462
+ timeout=self._timeout,
463
+ follow_redirects=False,
464
+ ) as response:
465
+ body = await self._read_limited(response)
466
+ except _BodyLimitExceeded as exc:
467
+ raise ApiConnectionError(
468
+ f"Response from {self._base_url} exceeded "
469
+ f"max_response_bytes={self._max_response_bytes}",
470
+ base_url=self._base_url,
471
+ request_may_have_been_sent=True,
472
+ ) from exc
473
+ except httpx.HTTPError as exc:
474
+ raise ApiConnectionError(
475
+ f"Request to {self._base_url} failed: {exc.__class__.__name__}",
476
+ base_url=self._base_url,
477
+ request_may_have_been_sent=_request_may_have_been_sent(exc),
478
+ ) from exc
479
+ return self._decode(response, body)
480
+ finally:
481
+ self._in_flight -= 1
482
+
483
+ async def _read_limited(self, response: httpx.Response) -> bytes:
484
+ declared = response.headers.get("Content-Length")
485
+ if (
486
+ self._max_response_bytes > 0
487
+ and declared is not None
488
+ and declared.isdigit()
489
+ and int(declared) > self._max_response_bytes
490
+ ):
491
+ raise _BodyLimitExceeded
492
+ body = bytearray()
493
+ async for chunk in response.aiter_bytes():
494
+ if (
495
+ self._max_response_bytes > 0
496
+ and len(chunk) > self._max_response_bytes - len(body)
497
+ ):
498
+ raise _BodyLimitExceeded
499
+ body.extend(chunk)
500
+ return bytes(body)
501
+
502
+ async def aclose(self) -> None:
503
+ if not self._closed and self._owns_http_client:
504
+ await self._client.aclose()
505
+ self._closed = True
506
+
507
+ async def __aenter__(self) -> AsyncAteve:
508
+ self._ensure_open()
509
+ return self
510
+
511
+ async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
512
+ await self.aclose()
ateve/errors.py ADDED
@@ -0,0 +1,124 @@
1
+ """Typed errors raised by the Ateve Python SDK."""
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class AteveError(Exception):
7
+ """Base class for all SDK errors."""
8
+
9
+
10
+ class ApiError(AteveError):
11
+ """The API returned a non-success HTTP status."""
12
+
13
+ def __init__(
14
+ self,
15
+ message: str,
16
+ *,
17
+ status_code: int,
18
+ request_id: Optional[str] = None,
19
+ code: Optional[str] = None,
20
+ param: Optional[str] = None,
21
+ error_type: Optional[str] = None,
22
+ doc_url: Optional[str] = None,
23
+ ) -> None:
24
+ super().__init__(message)
25
+ self.status_code = status_code
26
+ self.request_id = request_id
27
+ self.code = code
28
+ self.param = param
29
+ self.type = error_type
30
+ self.doc_url = doc_url
31
+
32
+ @property
33
+ def is_retryable(self) -> bool:
34
+ """Whether a caller-owned policy may consider the status retryable."""
35
+ return self.status_code == 429 or self.status_code >= 500
36
+
37
+
38
+ class InvalidRequestError(ApiError):
39
+ """The request was rejected by validation or routing."""
40
+
41
+
42
+ class AuthenticationError(ApiError):
43
+ """The API key was missing or invalid."""
44
+
45
+
46
+ class PaymentRequiredError(ApiError):
47
+ """The account has insufficient credit or exceeded its credit limit."""
48
+
49
+
50
+ class ServerError(ApiError):
51
+ """The API or its upstream search provider failed."""
52
+
53
+
54
+ class RateLimitError(ApiError):
55
+ """A server-side rate limit was exceeded."""
56
+
57
+ def __init__(
58
+ self,
59
+ message: str,
60
+ *,
61
+ status_code: int,
62
+ request_id: Optional[str] = None,
63
+ code: Optional[str] = None,
64
+ param: Optional[str] = None,
65
+ error_type: Optional[str] = None,
66
+ doc_url: Optional[str] = None,
67
+ retry_after_seconds: Optional[int] = None,
68
+ limit_scope: Optional[str] = None,
69
+ ) -> None:
70
+ super().__init__(
71
+ message,
72
+ status_code=status_code,
73
+ request_id=request_id,
74
+ code=code,
75
+ param=param,
76
+ error_type=error_type,
77
+ doc_url=doc_url,
78
+ )
79
+ self.retry_after_seconds = retry_after_seconds
80
+ self.limit_scope = limit_scope
81
+
82
+
83
+ class ApiConnectionError(AteveError):
84
+ """A DNS, connection, TLS, timeout, reset, or body-limit failure."""
85
+
86
+ def __init__(
87
+ self,
88
+ message: str,
89
+ *,
90
+ base_url: str,
91
+ request_may_have_been_sent: bool,
92
+ ) -> None:
93
+ super().__init__(message)
94
+ self.base_url = base_url
95
+ self.request_may_have_been_sent = request_may_have_been_sent
96
+
97
+
98
+ class ApiResponseError(AteveError):
99
+ """A success response could not be decoded as the documented schema."""
100
+
101
+ def __init__(
102
+ self,
103
+ message: str,
104
+ *,
105
+ status_code: int,
106
+ request_id: Optional[str],
107
+ base_url: str,
108
+ ) -> None:
109
+ super().__init__(message)
110
+ self.status_code = status_code
111
+ self.request_id = request_id
112
+ self.base_url = base_url
113
+ self.request_may_have_been_sent = True
114
+
115
+
116
+ class ConcurrencyLimitError(AteveError):
117
+ """Rejected locally before network I/O by the optional bulkhead."""
118
+
119
+ def __init__(self, max_concurrent_requests: int) -> None:
120
+ super().__init__(
121
+ "Concurrency limit reached "
122
+ f"(max_concurrent_requests={max_concurrent_requests}); request rejected"
123
+ )
124
+ self.max_concurrent_requests = max_concurrent_requests
ateve/models.py ADDED
@@ -0,0 +1,200 @@
1
+ """Pydantic request and response models for ``POST /v1/search``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import date, datetime
7
+ from enum import Enum
8
+ from typing import Any, Dict, List, Optional
9
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
12
+
13
+
14
+ class ContentFormat(str, Enum):
15
+ TEXT = "text"
16
+ MARKDOWN = "markdown"
17
+
18
+
19
+ class RequestModel(BaseModel):
20
+ model_config = ConfigDict(extra="forbid", frozen=True)
21
+
22
+
23
+ class ResponseModel(BaseModel):
24
+ model_config = ConfigDict(extra="allow", frozen=True)
25
+
26
+
27
+ class UserLocation(RequestModel):
28
+ country: Optional[str] = Field(default=None, pattern=r"^[A-Z]{2}$")
29
+ region: Optional[str] = None
30
+ city: Optional[str] = None
31
+ timezone: Optional[str] = None
32
+
33
+ @field_validator("timezone")
34
+ @classmethod
35
+ def validate_timezone(cls, value: Optional[str]) -> Optional[str]:
36
+ if value is None:
37
+ return value
38
+ try:
39
+ ZoneInfo(value)
40
+ except ZoneInfoNotFoundError as exc:
41
+ raise ValueError("timezone must be a valid IANA Time Zone identifier") from exc
42
+ if value in {"PST", "EST", "CST", "MST"} or re.match(r"^UTC[+-]", value):
43
+ raise ValueError("timezone must be a region-based IANA identifier")
44
+ return value
45
+
46
+
47
+ class LocaleSettings(RequestModel):
48
+ mkt: Optional[str] = Field(default=None, pattern=r"^[A-Z]{2}$")
49
+ language: Optional[str] = Field(default=None, pattern=r"^[a-z]{2}$")
50
+ user_location: Optional[UserLocation] = None
51
+
52
+
53
+ class ContentOptions(RequestModel):
54
+ snippet: Optional[bool] = None
55
+ raw_content: Optional[bool] = None
56
+ format: Optional[ContentFormat] = None
57
+ summary: Optional[bool] = None
58
+ highlights: Optional[bool] = None
59
+ max_characters: Optional[int] = Field(default=None, ge=1, le=2147483647, strict=True)
60
+ favicon: Optional[bool] = None
61
+
62
+
63
+ _DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
64
+
65
+
66
+ def _parse_date(value: str) -> date:
67
+ if not _DATE.fullmatch(value):
68
+ raise ValueError("date_range contains an invalid date shape")
69
+ try:
70
+ return date.fromisoformat(value)
71
+ except ValueError as exc:
72
+ raise ValueError(f"date_range contains an invalid calendar date: {value}") from exc
73
+
74
+
75
+ def _validate_date_range(value: Optional[str]) -> Optional[str]:
76
+ if value is None or value == "" or value in {"day", "week", "month", "year"}:
77
+ return value
78
+ if ".." not in value:
79
+ _parse_date(value)
80
+ return value
81
+ if value.count("..") != 1:
82
+ raise ValueError("date_range must contain at most one '..' separator")
83
+ start_text, end_text = value.split("..")
84
+ if not start_text and not end_text:
85
+ raise ValueError("date_range cannot be '..'")
86
+ start = _parse_date(start_text) if start_text else None
87
+ end = _parse_date(end_text) if end_text else None
88
+ if start is not None and end is not None and end < start:
89
+ raise ValueError("date_range end date must be on or after the start date")
90
+ return value
91
+
92
+
93
+ class SearchRequest(RequestModel):
94
+ query: str = Field(min_length=1, max_length=2000)
95
+ limit: Optional[int] = Field(default=None, ge=1, le=50)
96
+ offset: Optional[int] = Field(default=None, ge=0, le=99)
97
+ date_range: Optional[str] = None
98
+ locale: Optional[LocaleSettings] = None
99
+ include_domains: Optional[List[str]] = Field(default=None, max_length=300)
100
+ exclude_domains: Optional[List[str]] = Field(default=None, max_length=300)
101
+ content: Optional[ContentOptions] = None
102
+ safe_search: Optional[bool] = None
103
+
104
+ @field_validator("query")
105
+ @classmethod
106
+ def query_must_not_be_blank(cls, value: str) -> str:
107
+ if not value.strip():
108
+ raise ValueError("query is required")
109
+ return value
110
+
111
+ @field_validator("date_range")
112
+ @classmethod
113
+ def date_range_must_be_valid(cls, value: Optional[str]) -> Optional[str]:
114
+ return _validate_date_range(value)
115
+
116
+ @model_validator(mode="after")
117
+ def copy_domain_lists(self) -> SearchRequest:
118
+ # Pydantic already copies normal inputs; this assertion keeps the
119
+ # validation path explicit and rejects null list entries consistently.
120
+ for name in ("include_domains", "exclude_domains"):
121
+ domains = getattr(self, name)
122
+ if domains is not None and any(domain is None for domain in domains):
123
+ raise ValueError(f"{name} must not contain null entries")
124
+ return self
125
+
126
+
127
+ class ImageItem(ResponseModel):
128
+ url: str
129
+ width: Optional[int] = None
130
+ height: Optional[int] = None
131
+ alt: Optional[str] = None
132
+
133
+
134
+ class VideoItem(ResponseModel):
135
+ url: Optional[str] = None
136
+ thumbnail: Optional[str] = None
137
+ title: Optional[str] = None
138
+ duration_seconds: Optional[int] = None
139
+
140
+
141
+ class Citation(ResponseModel):
142
+ url: Optional[str] = None
143
+ title: Optional[str] = None
144
+ start_index: Optional[int] = None
145
+ end_index: Optional[int] = None
146
+ quote: Optional[str] = None
147
+
148
+
149
+ class AnswerBlock(ResponseModel):
150
+ content: Optional[str] = None
151
+ citations: Optional[List[Citation]] = None
152
+
153
+
154
+ class QueryInfo(ResponseModel):
155
+ original: str
156
+ effective: Optional[str] = None
157
+ auto_tuned: Optional[Dict[str, Any]] = None
158
+
159
+
160
+ class SearchResult(ResponseModel):
161
+ id: Optional[str] = None
162
+ title: Optional[str] = None
163
+ url: Optional[str] = None
164
+ display_url: Optional[str] = None
165
+ site_name: Optional[str] = None
166
+ language: Optional[str] = None
167
+ published_at: Optional[datetime] = None
168
+ score: Optional[float] = None
169
+ snippet: Optional[str] = None
170
+ raw_content: Optional[str] = None
171
+ summary: Optional[str] = None
172
+ highlights: Optional[List[str]] = None
173
+ favicon: Optional[str] = None
174
+ images: List[ImageItem] = Field(default_factory=list)
175
+ is_safe: Optional[bool] = None
176
+
177
+
178
+ class UsageBreakdown(ResponseModel):
179
+ search: Optional[int] = None
180
+ rerank: Optional[int] = None
181
+ answer: Optional[int] = None
182
+
183
+
184
+ class Usage(ResponseModel):
185
+ credits: Optional[int] = None
186
+ breakdown: Optional[UsageBreakdown] = None
187
+
188
+
189
+ class SearchResponse(ResponseModel):
190
+ id: str
191
+ created: int
192
+ latency_ms: int
193
+ query: QueryInfo
194
+ answer: Optional[AnswerBlock] = None
195
+ results: List[SearchResult]
196
+ news: Optional[List[SearchResult]] = None
197
+ images: Optional[List[ImageItem]] = None
198
+ videos: Optional[List[VideoItem]] = None
199
+ total_estimated_matches: Optional[int] = None
200
+ usage: Optional[Usage] = None
ateve/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.5
2
+ Name: ateve
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Ateve Search API
5
+ Project-URL: Homepage, https://ateve.ai
6
+ Project-URL: Documentation, https://ateve.ai/docs
7
+ Project-URL: Repository, https://github.com/ateve-inc/ateve-sdks
8
+ Author-email: "Ateve Inc." <support@ateve.ai>
9
+ License: Apache-2.0
10
+ Keywords: AI,RAG,ateve,search
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: httpx<1,>=0.28.1
23
+ Requires-Dist: pydantic<3,>=2.10
24
+ Requires-Dist: tzdata>=2025.2; platform_system == 'Windows'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # Ateve Python SDK
28
+
29
+ Official typed client for `POST /v1/search`. Requires Python 3.11+.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install ateve
35
+ ```
36
+
37
+ ## Synchronous
38
+
39
+ ```python
40
+ from ateve import Ateve, ContentOptions
41
+
42
+ with Ateve() as client: # reads ATEVE_API_KEY
43
+ response = client.search(
44
+ query="quantum computing",
45
+ date_range="week",
46
+ content=ContentOptions(summary=True, highlights=True),
47
+ )
48
+ print(response.results[0].title, response.usage.credits)
49
+ ```
50
+
51
+ ## Asynchronous
52
+
53
+ ```python
54
+ from ateve import AsyncAteve
55
+
56
+ async with AsyncAteve() as client:
57
+ response = await client.search("quantum computing", limit=10)
58
+ ```
59
+
60
+ `Ateve` owns and closes its default `httpx.Client`; `AsyncAteve` does the same
61
+ for its default `httpx.AsyncClient`. An injected `http_client` is borrowed and
62
+ never closed by the SDK. Reuse one Ateve client for its connection pool instead
63
+ of constructing one per request.
64
+
65
+ Each `search` call sends exactly one HTTP request. The SDK never retries,
66
+ backs off, or switches endpoints. This is intentional: a timeout/reset can
67
+ happen after the billable search already ran. `ApiConnectionError` exposes
68
+ `request_may_have_been_sent`; only DNS/connect/connect-timeout/pool-timeout
69
+ failures are classified as definitely not sent.
70
+
71
+ The built-in path defaults to a 60-second request timeout, 5-second connect
72
+ timeout, a 32 MiB response cap, and no redirects. Use
73
+ `max_concurrent_requests` for an optional fail-fast bulkhead.
74
+
75
+ Typed errors include `AuthenticationError`, `PaymentRequiredError`,
76
+ `RateLimitError`, `InvalidRequestError`, `ServerError`,
77
+ `ApiConnectionError`, `ApiResponseError`, and `ConcurrencyLimitError`.
78
+ `RateLimitError` preserves `retry_after_seconds` and `limit_scope`; retry
79
+ decisions remain caller-owned.
80
+
81
+ ## Search contract limits
82
+
83
+ Only the first 100 results are accessible: offset 0–99, limit 1–50. Pages crossing depth 100 are truncated. The content character limit defaults to 5000 per raw_content, after output format conversion; it does not enable raw_content. Use `ContentOptions(raw_content=True, max_characters=8000)`. The limit is a positive signed 32-bit integer with no fixed business cap.
@@ -0,0 +1,9 @@
1
+ ateve/__init__.py,sha256=M8HTtb3WTPvCewcLFHo84LAY4x7e5IhKiZFeBmgFgAI,1200
2
+ ateve/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
3
+ ateve/client.py,sha256=Ef45Pb99o8yxwnO5BtI71cMkWINuUWILtSen4r8iqsQ,17541
4
+ ateve/errors.py,sha256=dL19ZPnruG7YZAuGzuNxdfHAmkQ2GRAfOOaVgTThlJI,3431
5
+ ateve/models.py,sha256=XShrP8X5zfR-Qv2P9jzkClnjcUeC1mbVtrYA9gwmo3M,6515
6
+ ateve/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
7
+ ateve-0.1.0.dist-info/METADATA,sha256=mpZhwBZ4ae8DODoM99QUBx6zSKZ5BVLLCPl2y_JY3qg,3182
8
+ ateve-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ ateve-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any