ateve 0.0.1__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.

Potentially problematic release.


This version of ateve might be problematic. Click here for more details.

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