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 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)