vermin-sdk 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.
vermin/__init__.py ADDED
@@ -0,0 +1,90 @@
1
+ """Official Python SDK for Vermin (https://vermin.dev): web data for AI."""
2
+
3
+ from ._base import DEFAULT_BASE_URL, CrawlOptionsDict, MapOptionsDict, ScrapeOptionsDict, SearchOptionsDict
4
+ from ._client import AsyncCrawl, AsyncVermin, Crawl, Vermin
5
+ from ._errors import API_ERROR_CODES, CLIENT_ERROR_CODES, VerminError
6
+ from ._models import (
7
+ Action,
8
+ Brand,
9
+ BrandColor,
10
+ BrandFont,
11
+ BrandImage,
12
+ BrandResponse,
13
+ BrandSocial,
14
+ Chunk,
15
+ CrawlError,
16
+ CrawlEvent,
17
+ CrawlJob,
18
+ CrawlStatus,
19
+ Document,
20
+ ExtractResponse,
21
+ ExtractSource,
22
+ Location,
23
+ MapLink,
24
+ MapResponse,
25
+ Metadata,
26
+ Quality,
27
+ ScrapeOptions,
28
+ ScrapeResponse,
29
+ SearchHit,
30
+ SearchResponse,
31
+ VerminModel,
32
+ Webhook,
33
+ )
34
+ from ._version import __version__
35
+ from ._webhooks import (
36
+ WEBHOOK_SIGNATURE_HEADER,
37
+ WEBHOOK_TOLERANCE_SECONDS,
38
+ is_valid_webhook,
39
+ sign_webhook,
40
+ verify_webhook,
41
+ verify_webhook_signature,
42
+ )
43
+
44
+ __all__ = [
45
+ "API_ERROR_CODES",
46
+ "CLIENT_ERROR_CODES",
47
+ "DEFAULT_BASE_URL",
48
+ "Action",
49
+ "AsyncCrawl",
50
+ "AsyncVermin",
51
+ "Brand",
52
+ "BrandColor",
53
+ "BrandFont",
54
+ "BrandImage",
55
+ "BrandResponse",
56
+ "BrandSocial",
57
+ "Chunk",
58
+ "Crawl",
59
+ "CrawlError",
60
+ "CrawlEvent",
61
+ "CrawlJob",
62
+ "CrawlOptionsDict",
63
+ "CrawlStatus",
64
+ "Document",
65
+ "ExtractResponse",
66
+ "ExtractSource",
67
+ "Location",
68
+ "MapLink",
69
+ "MapOptionsDict",
70
+ "MapResponse",
71
+ "Metadata",
72
+ "Quality",
73
+ "ScrapeOptions",
74
+ "ScrapeOptionsDict",
75
+ "ScrapeResponse",
76
+ "SearchHit",
77
+ "SearchOptionsDict",
78
+ "SearchResponse",
79
+ "Vermin",
80
+ "VerminError",
81
+ "VerminModel",
82
+ "WEBHOOK_SIGNATURE_HEADER",
83
+ "WEBHOOK_TOLERANCE_SECONDS",
84
+ "Webhook",
85
+ "__version__",
86
+ "is_valid_webhook",
87
+ "sign_webhook",
88
+ "verify_webhook",
89
+ "verify_webhook_signature",
90
+ ]
vermin/_base.py ADDED
@@ -0,0 +1,269 @@
1
+ """Shared plumbing for the sync and async clients: config, request building, retries, parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import random
8
+ import uuid
9
+ from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Type, TypeVar, Union
10
+
11
+ import httpx
12
+ from pydantic import BaseModel, ValidationError
13
+
14
+ from ._errors import VerminError
15
+ from ._models import CrawlEvent, CrawlStatus, Document, ScrapeOptions, Webhook
16
+ from ._sse import SSEMessage
17
+ from ._version import __version__
18
+
19
+ from typing_extensions import TypedDict
20
+
21
+ DEFAULT_BASE_URL = "https://api.vermin.dev"
22
+ DEFAULT_TIMEOUT = 60.0
23
+ DEFAULT_MAX_RETRIES = 2
24
+
25
+ M = TypeVar("M", bound=BaseModel)
26
+
27
+
28
+ # ---- Keyword-argument shapes (snake_case; camelCase keys are accepted too) ---
29
+
30
+
31
+ class ScrapeOptionsDict(TypedDict, total=False):
32
+ formats: List[str]
33
+ only_main_content: bool
34
+ site_markdown: bool
35
+ include_tags: List[str]
36
+ exclude_tags: List[str]
37
+ tier: str
38
+ allow_premium: bool
39
+ actions: List[Dict[str, Any]]
40
+ wait_for: int
41
+ timeout: int
42
+ """Server-side page timeout in ms (1000–60000), not the HTTP client timeout."""
43
+ max_age: int
44
+ headers: Dict[str, str]
45
+ location: Dict[str, Any]
46
+ chunk_size: int
47
+ ignore_robots: bool
48
+
49
+
50
+ ScrapeOptionsInput = Union[ScrapeOptions, ScrapeOptionsDict, Mapping[str, Any]]
51
+
52
+
53
+ class CrawlOptionsDict(TypedDict, total=False):
54
+ limit: int
55
+ max_depth: int
56
+ include_paths: List[str]
57
+ exclude_paths: List[str]
58
+ allow_subdomains: bool
59
+ sitemap: str
60
+ remove_boilerplate: bool
61
+ ignore_robots: bool
62
+ scrape_options: ScrapeOptionsInput
63
+ webhook: Union[Webhook, Mapping[str, Any]]
64
+
65
+
66
+ class MapOptionsDict(TypedDict, total=False):
67
+ search: str
68
+ limit: int
69
+ include_subdomains: bool
70
+ sitemap: str
71
+ include_paths: List[str]
72
+ """Only return URLs whose path matches one of these globs."""
73
+ exclude_paths: List[str]
74
+ """Never return URLs whose path matches one of these globs."""
75
+
76
+
77
+ class SearchOptionsDict(TypedDict, total=False):
78
+ limit: int
79
+ country: str
80
+ language: str
81
+ time_range: str
82
+ scrape_options: ScrapeOptionsInput
83
+
84
+
85
+ # ---- Helpers -----------------------------------------------------------------
86
+
87
+
88
+ def camel(key: str) -> str:
89
+ if "_" not in key:
90
+ return key
91
+ head, *rest = key.split("_")
92
+ return head + "".join(p[:1].upper() + p[1:] for p in rest)
93
+
94
+
95
+ def scrape_options_wire(value: Optional[ScrapeOptionsInput]) -> Optional[Dict[str, Any]]:
96
+ if value is None:
97
+ return None
98
+ model = value if isinstance(value, ScrapeOptions) else ScrapeOptions.model_validate(dict(value))
99
+ return model.to_dict()
100
+
101
+
102
+ def wire(options: Mapping[str, Any]) -> Dict[str, Any]:
103
+ """Convert snake_case keyword options to the camelCase wire body (``None`` values dropped)."""
104
+ body: Dict[str, Any] = {}
105
+ for key, value in options.items():
106
+ if value is None:
107
+ continue
108
+ if key in ("scrape_options", "scrapeOptions"):
109
+ value = scrape_options_wire(value)
110
+ elif key == "webhook":
111
+ value = (value if isinstance(value, Webhook) else Webhook.model_validate(dict(value))).to_dict()
112
+ body[camel(key)] = value
113
+ return body
114
+
115
+
116
+ def scrape_body(url: str, options: Mapping[str, Any]) -> Dict[str, Any]:
117
+ return {"url": url, **ScrapeOptions.model_validate(dict(options)).to_dict()}
118
+
119
+
120
+ def extract_schema(schema: Any) -> Optional[Dict[str, Any]]:
121
+ if schema is None:
122
+ return None
123
+ if isinstance(schema, type) and issubclass(schema, BaseModel):
124
+ return schema.model_json_schema()
125
+ if isinstance(schema, Mapping):
126
+ return dict(schema)
127
+ raise TypeError("schema must be a JSON Schema dict or a pydantic BaseModel subclass")
128
+
129
+
130
+ def read_env(name: str) -> Optional[str]:
131
+ return os.environ.get(name) or None
132
+
133
+
134
+ def backoff_seconds(attempt: int) -> float:
135
+ """Exponential backoff with jitter: 0.5s, 1s, 2s… capped at 8s, randomised to 50–100%."""
136
+ base: float = min(8.0, 0.5 * 2.0**attempt)
137
+ return base / 2 + random.random() * (base / 2)
138
+
139
+
140
+ class Request(NamedTuple):
141
+ method: str
142
+ path: str
143
+ params: Optional[Dict[str, Any]] = None
144
+ json: Optional[Dict[str, Any]] = None
145
+ idempotency_key: Optional[str] = None
146
+ """Set for POSTs that accept an ``Idempotency-Key``; reused across retries."""
147
+ headers: Optional[Dict[str, str]] = None
148
+
149
+
150
+ def idempotent(key: Optional[str]) -> str:
151
+ return key or str(uuid.uuid4())
152
+
153
+
154
+ class BaseClient:
155
+ api_key: str
156
+ base_url: str
157
+ timeout: float
158
+ max_retries: int
159
+
160
+ def __init__(
161
+ self,
162
+ api_key: Optional[str],
163
+ base_url: Optional[str],
164
+ timeout: Optional[float],
165
+ max_retries: Optional[int],
166
+ headers: Optional[Mapping[str, str]],
167
+ ) -> None:
168
+ key = api_key or read_env("VERMIN_API_KEY")
169
+ if not key:
170
+ raise VerminError(
171
+ "missing_api_key",
172
+ "Missing API key: pass api_key=... or set the VERMIN_API_KEY environment variable.",
173
+ )
174
+ self.api_key = key
175
+ self.base_url = (base_url or read_env("VERMIN_API_URL") or DEFAULT_BASE_URL).rstrip("/")
176
+ self.timeout = DEFAULT_TIMEOUT if timeout is None else timeout
177
+ self.max_retries = max(0, DEFAULT_MAX_RETRIES if max_retries is None else max_retries)
178
+ self._extra_headers = dict(headers or {})
179
+
180
+ def _url(self, path: str) -> str:
181
+ return self.base_url + path
182
+
183
+ def _headers(self, req: Request, accept: str = "application/json") -> Dict[str, str]:
184
+ headers = {
185
+ "Authorization": f"Bearer {self.api_key}",
186
+ "Accept": accept,
187
+ "User-Agent": f"vermin-python/{__version__}",
188
+ **self._extra_headers,
189
+ **(req.headers or {}),
190
+ }
191
+ if req.idempotency_key:
192
+ headers["Idempotency-Key"] = req.idempotency_key
193
+ return headers
194
+
195
+ @staticmethod
196
+ def _params(req: Request) -> Optional[Dict[str, Any]]:
197
+ if not req.params:
198
+ return None
199
+ return {k: (str(v).lower() if isinstance(v, bool) else v) for k, v in req.params.items() if v is not None}
200
+
201
+ @staticmethod
202
+ def _transport_error(exc: Exception, timeout: float) -> VerminError:
203
+ if isinstance(exc, httpx.TimeoutException):
204
+ err = VerminError("timeout", f"Request timed out after {timeout}s")
205
+ else:
206
+ err = VerminError("connection_error", f"Connection error: {exc}")
207
+ err.__cause__ = exc
208
+ return err
209
+
210
+ @staticmethod
211
+ def _retry_delay(err: VerminError, attempt: int) -> float:
212
+ if err.retry_after_ms is not None:
213
+ return err.retry_after_ms / 1000
214
+ return backoff_seconds(attempt)
215
+
216
+ @staticmethod
217
+ def _parse(model: Type[M], response: httpx.Response) -> M:
218
+ try:
219
+ body = response.json()
220
+ result = model.model_validate(body)
221
+ except (ValueError, ValidationError) as exc:
222
+ err = VerminError(
223
+ "invalid_response",
224
+ f"Could not parse {model.__name__} from the response: {exc}",
225
+ status=response.status_code,
226
+ request_id=response.headers.get("x-request-id"),
227
+ body=response.text,
228
+ )
229
+ raise err from exc
230
+ if getattr(result, "request_id", "") is None and response.headers.get("x-request-id"):
231
+ result.request_id = response.headers["x-request-id"] # type: ignore[attr-defined]
232
+ return result
233
+
234
+ @staticmethod
235
+ def _crawl_event(msg: SSEMessage) -> Optional[CrawlEvent]:
236
+ if msg.event not in ("page", "status", "done"):
237
+ return None
238
+ try:
239
+ data = json.loads(msg.data)
240
+ parsed: Union[Document, CrawlStatus] = (
241
+ Document.model_validate(data) if msg.event == "page" else CrawlStatus.model_validate(data)
242
+ )
243
+ except (ValueError, ValidationError) as exc:
244
+ raise VerminError("invalid_response", f"Invalid {msg.event!r} event: {exc}", body=msg.data) from exc
245
+ return CrawlEvent(type=msg.event, id=msg.id, data=parsed)
246
+
247
+ @staticmethod
248
+ def _validate_extract(schema: Any, response: Any) -> Any:
249
+ if isinstance(schema, type) and issubclass(schema, BaseModel):
250
+ try:
251
+ response.data = schema.model_validate(response.data)
252
+ except ValidationError as exc:
253
+ raise VerminError(
254
+ "invalid_response",
255
+ f"Extracted data did not match {schema.__name__}: {exc}",
256
+ status=200,
257
+ request_id=response.request_id,
258
+ body=response.data,
259
+ ) from exc
260
+ return response
261
+
262
+ @staticmethod
263
+ def _stream_closed(reconnects: int, cause: Optional[BaseException]) -> VerminError:
264
+ err = VerminError(
265
+ "connection_error",
266
+ f'Crawl event stream closed before "done" (after {reconnects} reconnects)',
267
+ )
268
+ err.__cause__ = cause
269
+ return err