serpkite 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.
- serpkite/__init__.py +48 -0
- serpkite/_base.py +203 -0
- serpkite/_client.py +1614 -0
- serpkite/_exceptions.py +169 -0
- serpkite/_models.py +863 -0
- serpkite/_version.py +1 -0
- serpkite/crewai.py +133 -0
- serpkite/py.typed +0 -0
- serpkite/types.py +264 -0
- serpkite/webhooks.py +49 -0
- serpkite-0.1.0.dist-info/METADATA +295 -0
- serpkite-0.1.0.dist-info/RECORD +14 -0
- serpkite-0.1.0.dist-info/WHEEL +4 -0
- serpkite-0.1.0.dist-info/licenses/LICENSE +21 -0
serpkite/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""SerpKite: the Google search API built for AI agents.
|
|
2
|
+
|
|
3
|
+
>>> from serpkite import SerpKite
|
|
4
|
+
>>> sk = SerpKite() # reads SERPKITE_API_KEY
|
|
5
|
+
>>> res = sk.search("best espresso machine", country="us")
|
|
6
|
+
>>> print(res.results[0].title, res.meta.credits_used)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from . import types
|
|
10
|
+
from ._base import DEFAULT_BASE_URL
|
|
11
|
+
from ._client import AsyncBatches, AsyncSerpKite, Batches, SerpKite
|
|
12
|
+
from ._exceptions import (
|
|
13
|
+
APIConnectionError,
|
|
14
|
+
APIError,
|
|
15
|
+
APITimeoutError,
|
|
16
|
+
AuthenticationError,
|
|
17
|
+
BadRequestError,
|
|
18
|
+
BatchTimeoutError,
|
|
19
|
+
InsufficientCreditsError,
|
|
20
|
+
NotFoundError,
|
|
21
|
+
PermissionDeniedError,
|
|
22
|
+
RateLimitError,
|
|
23
|
+
SerpKiteError,
|
|
24
|
+
)
|
|
25
|
+
from ._version import __version__
|
|
26
|
+
from .webhooks import verify_webhook
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"DEFAULT_BASE_URL",
|
|
30
|
+
"APIConnectionError",
|
|
31
|
+
"APIError",
|
|
32
|
+
"APITimeoutError",
|
|
33
|
+
"AsyncBatches",
|
|
34
|
+
"AsyncSerpKite",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"BadRequestError",
|
|
37
|
+
"BatchTimeoutError",
|
|
38
|
+
"Batches",
|
|
39
|
+
"InsufficientCreditsError",
|
|
40
|
+
"NotFoundError",
|
|
41
|
+
"PermissionDeniedError",
|
|
42
|
+
"RateLimitError",
|
|
43
|
+
"SerpKite",
|
|
44
|
+
"SerpKiteError",
|
|
45
|
+
"__version__",
|
|
46
|
+
"types",
|
|
47
|
+
"verify_webhook",
|
|
48
|
+
]
|
serpkite/_base.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Transport-independent pieces shared by the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import email.utils
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any, Optional, TypeVar, Union
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from ._exceptions import SerpKiteError, from_response
|
|
17
|
+
from ._version import __version__
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "https://api.serpkite.com"
|
|
20
|
+
DEFAULT_TIMEOUT = 60.0
|
|
21
|
+
DEFAULT_MAX_RETRIES = 2
|
|
22
|
+
API_KEY_ENV = "SERPKITE_API_KEY"
|
|
23
|
+
BASE_URL_ENV = "SERPKITE_BASE_URL"
|
|
24
|
+
|
|
25
|
+
INITIAL_RETRY_DELAY = 0.5
|
|
26
|
+
MAX_RETRY_DELAY = 8.0
|
|
27
|
+
MAX_RETRY_AFTER = 60.0
|
|
28
|
+
|
|
29
|
+
# The API sends upstream failures as 503; 502/504 stay for proxies in between.
|
|
30
|
+
RETRY_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
|
|
31
|
+
# 429s that won't clear by waiting a few seconds.
|
|
32
|
+
NON_RETRYABLE_CODES = frozenset({"daily_limit_reached"})
|
|
33
|
+
|
|
34
|
+
M = TypeVar("M", bound=BaseModel)
|
|
35
|
+
|
|
36
|
+
Timeout = Union[float, httpx.Timeout, None]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_api_key(api_key: Optional[str]) -> str:
|
|
40
|
+
key = api_key if api_key is not None else os.environ.get(API_KEY_ENV)
|
|
41
|
+
if not key or not key.strip():
|
|
42
|
+
raise SerpKiteError(
|
|
43
|
+
None,
|
|
44
|
+
"missing_api_key",
|
|
45
|
+
f"No SerpKite API key. Pass api_key=... or set the {API_KEY_ENV} environment variable "
|
|
46
|
+
"(create a key at https://app.serpkite.com/keys).",
|
|
47
|
+
)
|
|
48
|
+
return key.strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_base_url(base_url: Optional[str]) -> str:
|
|
52
|
+
url = base_url or os.environ.get(BASE_URL_ENV) or DEFAULT_BASE_URL
|
|
53
|
+
return url.rstrip("/")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def user_agent() -> str:
|
|
57
|
+
return f"serpkite-python/{__version__} python/{platform.python_version()}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def clean(body: Mapping[str, Any]) -> dict[str, Any]:
|
|
61
|
+
"""Drops ``None`` values so server-side defaults apply.
|
|
62
|
+
|
|
63
|
+
A non-string ``engine`` sequence (tuple, list…) is sent as a JSON array.
|
|
64
|
+
"""
|
|
65
|
+
out = {k: v for k, v in body.items() if v is not None}
|
|
66
|
+
engine = out.get("engine")
|
|
67
|
+
if engine is not None and not isinstance(engine, str):
|
|
68
|
+
out["engine"] = list(engine)
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def retry_delay(attempt: int, response: Optional[httpx.Response]) -> float:
|
|
73
|
+
"""Seconds to wait before retry number ``attempt`` (0-based).
|
|
74
|
+
|
|
75
|
+
Honors ``Retry-After`` (seconds or an HTTP date), otherwise exponential backoff with jitter.
|
|
76
|
+
"""
|
|
77
|
+
if response is not None:
|
|
78
|
+
after = parse_retry_after(response.headers.get("retry-after"))
|
|
79
|
+
if after is not None:
|
|
80
|
+
return min(after, MAX_RETRY_AFTER)
|
|
81
|
+
base = min(INITIAL_RETRY_DELAY * 2.0**attempt, MAX_RETRY_DELAY)
|
|
82
|
+
return base * (0.75 + random.random() * 0.5)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_retry_after(value: Optional[str]) -> Optional[float]:
|
|
86
|
+
if not value:
|
|
87
|
+
return None
|
|
88
|
+
try:
|
|
89
|
+
return max(float(value), 0.0)
|
|
90
|
+
except ValueError:
|
|
91
|
+
pass
|
|
92
|
+
try:
|
|
93
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
return None
|
|
96
|
+
return max(when.timestamp() - time.time(), 0.0)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def error_code(response: httpx.Response) -> Optional[str]:
|
|
100
|
+
try:
|
|
101
|
+
body = response.json()
|
|
102
|
+
except ValueError:
|
|
103
|
+
return None
|
|
104
|
+
if isinstance(body, dict) and isinstance(body.get("error"), dict):
|
|
105
|
+
code = body["error"].get("code")
|
|
106
|
+
return str(code) if code is not None else None
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def should_retry(response: httpx.Response, idempotent: bool) -> bool:
|
|
111
|
+
status = response.status_code
|
|
112
|
+
if status not in RETRY_STATUSES:
|
|
113
|
+
return False
|
|
114
|
+
if status == 429:
|
|
115
|
+
return error_code(response) not in NON_RETRYABLE_CODES
|
|
116
|
+
# A 5xx on a non-idempotent call (batch creation) may already have been applied.
|
|
117
|
+
return idempotent
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def retryable_exception(exc: httpx.TransportError, idempotent: bool) -> bool:
|
|
121
|
+
if idempotent:
|
|
122
|
+
return True
|
|
123
|
+
# The request provably never reached the server.
|
|
124
|
+
return isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_body(
|
|
128
|
+
response: httpx.Response,
|
|
129
|
+
model: Optional[type[M]],
|
|
130
|
+
fmt: Optional[str],
|
|
131
|
+
fields: Optional[str],
|
|
132
|
+
) -> Any:
|
|
133
|
+
"""Turns a 2xx response into a str (Markdown), a dict (compact / projected) or a model."""
|
|
134
|
+
if response.status_code >= 400:
|
|
135
|
+
raise from_response(response)
|
|
136
|
+
ctype = response.headers.get("content-type", "")
|
|
137
|
+
if fmt == "markdown" or ctype.startswith("text/"):
|
|
138
|
+
return response.text
|
|
139
|
+
try:
|
|
140
|
+
data = response.json()
|
|
141
|
+
except ValueError as exc:
|
|
142
|
+
raise SerpKiteError(
|
|
143
|
+
response.status_code,
|
|
144
|
+
"invalid_response",
|
|
145
|
+
"expected a JSON response",
|
|
146
|
+
response.headers.get("x-request-id"),
|
|
147
|
+
response=response,
|
|
148
|
+
) from exc
|
|
149
|
+
if model is None or fmt == "compact" or fields:
|
|
150
|
+
return data
|
|
151
|
+
return model.model_validate(data)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class Config:
|
|
155
|
+
"""Resolved client options."""
|
|
156
|
+
|
|
157
|
+
__slots__ = ("api_key", "base_url", "headers", "max_retries", "timeout")
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
api_key: Optional[str],
|
|
162
|
+
base_url: Optional[str],
|
|
163
|
+
timeout: Timeout,
|
|
164
|
+
max_retries: int,
|
|
165
|
+
default_headers: Optional[Mapping[str, str]],
|
|
166
|
+
) -> None:
|
|
167
|
+
if max_retries < 0:
|
|
168
|
+
raise ValueError("max_retries must be >= 0")
|
|
169
|
+
self.api_key = resolve_api_key(api_key)
|
|
170
|
+
self.base_url = resolve_base_url(base_url)
|
|
171
|
+
self.timeout = timeout
|
|
172
|
+
self.max_retries = max_retries
|
|
173
|
+
self.headers: dict[str, str] = {
|
|
174
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
175
|
+
"Accept": "application/json, text/markdown",
|
|
176
|
+
"User-Agent": user_agent(),
|
|
177
|
+
**(default_headers or {}),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
def build(
|
|
181
|
+
self,
|
|
182
|
+
client: Union[httpx.Client, httpx.AsyncClient],
|
|
183
|
+
method: str,
|
|
184
|
+
path: str,
|
|
185
|
+
json: Optional[Mapping[str, Any]],
|
|
186
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
187
|
+
) -> httpx.Request:
|
|
188
|
+
return client.build_request(
|
|
189
|
+
method,
|
|
190
|
+
self.base_url + path,
|
|
191
|
+
json=dict(json) if json is not None else None,
|
|
192
|
+
headers={**self.headers, **headers} if headers else self.headers,
|
|
193
|
+
timeout=self.timeout,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def vertical_body(base: Mapping[str, Any], fmt: Optional[str], fields: Optional[str]) -> dict[str, Any]:
|
|
198
|
+
body = dict(base)
|
|
199
|
+
if fmt is not None and fmt != "json":
|
|
200
|
+
body["format"] = fmt
|
|
201
|
+
if fields:
|
|
202
|
+
body["fields"] = fields
|
|
203
|
+
return clean(body)
|