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 +64 -0
- ateve/_version.py +1 -0
- ateve/client.py +520 -0
- ateve/errors.py +124 -0
- ateve/models.py +217 -0
- ateve/py.typed +1 -0
- ateve-0.0.1.dist-info/METADATA +80 -0
- ateve-0.0.1.dist-info/RECORD +9 -0
- ateve-0.0.1.dist-info/WHEEL +4 -0
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,,
|