crawlforge 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.
crawlforge/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """CrawlForge Python SDK: 30 metered web tools behind one API key.
2
+
3
+ from crawlforge import CrawlForge
4
+
5
+ client = CrawlForge() # reads CRAWLFORGE_API_KEY
6
+ result = client.scrape(url="https://example.com", formats=["markdown"])
7
+ print(result.data["formats"]["markdown"], result.credits_remaining)
8
+ """
9
+
10
+ from crawlforge._client import AsyncCrawlForge, CrawlForge
11
+ from crawlforge._generated.models import ToolInfo
12
+ from crawlforge._generated.tools import TOOLS
13
+ from crawlforge._version import __version__
14
+ from crawlforge.errors import (
15
+ AuthenticationError,
16
+ CrawlForgeError,
17
+ InsufficientCreditsError,
18
+ RateLimitError,
19
+ ToolError,
20
+ ValidationError,
21
+ )
22
+ from crawlforge.types import ToolResult, ToolSpec
23
+
24
+ __all__ = [
25
+ "AsyncCrawlForge",
26
+ "AuthenticationError",
27
+ "CrawlForge",
28
+ "CrawlForgeError",
29
+ "InsufficientCreditsError",
30
+ "RateLimitError",
31
+ "TOOLS",
32
+ "ToolError",
33
+ "ToolInfo",
34
+ "ToolResult",
35
+ "ToolSpec",
36
+ "ValidationError",
37
+ "__version__",
38
+ ]
crawlforge/_client.py ADDED
@@ -0,0 +1,300 @@
1
+ """The sync and async clients: headers, validation, retries and response mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import copy
7
+ import os
8
+ import random
9
+ import re
10
+ import time
11
+ from typing import Any, Dict, Mapping, Optional, TypeVar
12
+
13
+ import httpx
14
+ from pydantic import BaseModel
15
+ from pydantic import ValidationError as PydanticValidationError
16
+
17
+ from crawlforge._generated.models import ToolInfo
18
+ from crawlforge._generated.tools import TOOLS, AsyncToolsMixin, SyncToolsMixin
19
+ from crawlforge._version import __version__
20
+ from crawlforge.errors import (
21
+ CrawlForgeError,
22
+ error_from_response,
23
+ parse_retry_after,
24
+ request_id_from,
25
+ )
26
+ from crawlforge.types import ToolResult
27
+
28
+ DEFAULT_BASE_URL = "https://www.crawlforge.dev/api/v1"
29
+ DEFAULT_MAX_RETRIES = 2
30
+ DEFAULT_TIMEOUT = 60.0
31
+ MAX_RETRY_WAIT = 60.0
32
+ USER_AGENT = f"crawlforge-sdk-python/{__version__}"
33
+
34
+ _TOOL_NAME = re.compile(r"[a-z0-9_]+")
35
+ _ClientT = TypeVar("_ClientT", bound="_Core")
36
+
37
+ # Module attributes so tests can replace the waits without touching the stdlib.
38
+ _sleep = time.sleep
39
+ _async_sleep = asyncio.sleep
40
+
41
+
42
+ def retry_wait(retry_after: Optional[float], attempt: int) -> float:
43
+ """Seconds to wait before retry number ``attempt`` (0-based) of a 429.
44
+
45
+ Retry-After wins when present; otherwise exponential backoff from 1 s with
46
+ +/-20 % jitter. Either way the wait is capped at 60 s.
47
+ """
48
+ if retry_after is not None:
49
+ return min(retry_after, MAX_RETRY_WAIT)
50
+ return min((2.0**attempt) * random.uniform(0.8, 1.2), MAX_RETRY_WAIT)
51
+
52
+
53
+ class _Core:
54
+ def __init__(
55
+ self,
56
+ api_key: Optional[str],
57
+ base_url: Optional[str],
58
+ max_retries: int,
59
+ timeout: float,
60
+ ) -> None:
61
+ key = api_key if api_key is not None else os.environ.get("CRAWLFORGE_API_KEY")
62
+ if not key:
63
+ raise CrawlForgeError(
64
+ 0,
65
+ "MISSING_API_KEY",
66
+ "No API key: pass api_key=... or set CRAWLFORGE_API_KEY. "
67
+ "Keys are created at https://www.crawlforge.dev/dashboard/keys",
68
+ )
69
+ self._api_key = key
70
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
71
+ self.max_retries = max_retries
72
+ self.timeout = timeout
73
+ self._owns_http = False
74
+
75
+ def __repr__(self) -> str:
76
+ # Never the key.
77
+ return f"{type(self).__name__}(base_url={self.base_url!r})"
78
+
79
+ def with_options(
80
+ self: _ClientT, *, timeout: Optional[float] = None, max_retries: Optional[int] = None
81
+ ) -> _ClientT:
82
+ """A copy of this client with a different per-request timeout or retry budget.
83
+
84
+ The copy shares the HTTP connection pool; closing it does not close the original.
85
+ """
86
+ clone = copy.copy(self)
87
+ clone._owns_http = False
88
+ if timeout is not None:
89
+ clone.timeout = timeout
90
+ if max_retries is not None:
91
+ clone.max_retries = max_retries
92
+ return clone
93
+
94
+ def _url(self, tool: str) -> str:
95
+ if not _TOOL_NAME.fullmatch(tool):
96
+ raise ValueError(f"invalid tool name {tool!r}")
97
+ return f"{self.base_url}/tools/{tool}"
98
+
99
+ def _headers(self, *, auth: bool, body: bool) -> Dict[str, str]:
100
+ headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
101
+ if body:
102
+ headers["Content-Type"] = "application/json"
103
+ if auth:
104
+ headers["X-API-Key"] = self._api_key
105
+ return headers
106
+
107
+ @staticmethod
108
+ def _body(tool: str, request: Any, params: Mapping[str, Any]) -> Dict[str, Any]:
109
+ """Validate a typed call locally and return the JSON body to send."""
110
+ model_cls = TOOLS[tool].request_model
111
+ given = {key: value for key, value in params.items() if value is not None}
112
+ if request is not None and given:
113
+ raise TypeError(
114
+ f"{tool}(): pass either a request object or keyword arguments, not both"
115
+ )
116
+ if request is None:
117
+ model = model_cls.model_validate(given)
118
+ elif isinstance(request, model_cls):
119
+ model = request
120
+ elif isinstance(request, Mapping):
121
+ model = model_cls.model_validate(dict(request))
122
+ else:
123
+ raise TypeError(
124
+ f"{tool}(): expected {model_cls.__name__} or a dict, got {type(request).__name__}"
125
+ )
126
+ return model.model_dump(exclude_unset=True, by_alias=True, mode="json")
127
+
128
+ @staticmethod
129
+ def _json(response: httpx.Response) -> Any:
130
+ try:
131
+ return response.json()
132
+ except ValueError:
133
+ raise CrawlForgeError(
134
+ response.status_code,
135
+ "UNEXPECTED_RESPONSE",
136
+ f"HTTP {response.status_code} with a non-JSON body",
137
+ response.text,
138
+ request_id_from(response.headers),
139
+ ) from None
140
+
141
+ def _result(self, response: httpx.Response) -> ToolResult:
142
+ body = self._json(response)
143
+ if response.status_code >= 400 or (isinstance(body, dict) and body.get("success") is False):
144
+ raise error_from_response(response.status_code, body, response.headers)
145
+ try:
146
+ return ToolResult.model_validate(body)
147
+ except PydanticValidationError:
148
+ raise CrawlForgeError(
149
+ response.status_code,
150
+ "UNEXPECTED_RESPONSE",
151
+ f"HTTP {response.status_code} without a ToolSuccess envelope",
152
+ body,
153
+ request_id_from(response.headers),
154
+ ) from None
155
+
156
+ def _info(self, response: httpx.Response) -> ToolInfo:
157
+ body = self._json(response)
158
+ if response.status_code >= 400:
159
+ raise error_from_response(response.status_code, body, response.headers)
160
+ try:
161
+ return ToolInfo.model_validate(body)
162
+ except PydanticValidationError:
163
+ raise CrawlForgeError(
164
+ response.status_code,
165
+ "UNEXPECTED_RESPONSE",
166
+ f"HTTP {response.status_code} without a ToolInfo body",
167
+ body,
168
+ request_id_from(response.headers),
169
+ ) from None
170
+
171
+ @staticmethod
172
+ def _network_error(exc: httpx.TransportError) -> CrawlForgeError:
173
+ return CrawlForgeError(0, "NETWORK_ERROR", f"{type(exc).__name__}: {exc}")
174
+
175
+
176
+ class CrawlForge(_Core, SyncToolsMixin):
177
+ """Synchronous client for the CrawlForge REST API.
178
+
179
+ ``api_key`` falls back to the CRAWLFORGE_API_KEY environment variable.
180
+ ``http_client`` injects an ``httpx.Client`` (its transport is used as is;
181
+ headers, URLs and timeouts come from this client). Only a 429 is retried:
182
+ ``max_retries`` counts retries, so 2 means up to three requests.
183
+ """
184
+
185
+ def __init__(
186
+ self,
187
+ api_key: Optional[str] = None,
188
+ base_url: Optional[str] = None,
189
+ max_retries: int = DEFAULT_MAX_RETRIES,
190
+ timeout: float = DEFAULT_TIMEOUT,
191
+ http_client: Optional[httpx.Client] = None,
192
+ ) -> None:
193
+ super().__init__(api_key, base_url, max_retries, timeout)
194
+ self._http = http_client if http_client is not None else httpx.Client()
195
+ self._owns_http = http_client is None
196
+
197
+ def close(self) -> None:
198
+ if self._owns_http:
199
+ self._http.close()
200
+
201
+ def __enter__(self) -> CrawlForge:
202
+ return self
203
+
204
+ def __exit__(self, *exc: object) -> None:
205
+ self.close()
206
+
207
+ def _send(
208
+ self, method: str, url: str, body: Optional[Dict[str, Any]], *, auth: bool
209
+ ) -> httpx.Response:
210
+ attempt = 0
211
+ while True:
212
+ try:
213
+ response = self._http.request(
214
+ method,
215
+ url,
216
+ json=body,
217
+ headers=self._headers(auth=auth, body=body is not None),
218
+ timeout=self.timeout,
219
+ )
220
+ except httpx.TransportError as exc:
221
+ raise self._network_error(exc) from exc
222
+ if response.status_code == 429 and attempt < self.max_retries:
223
+ _sleep(retry_wait(parse_retry_after(response.headers), attempt))
224
+ attempt += 1
225
+ continue
226
+ return response
227
+
228
+ def _run_tool(self, tool: str, request: Any, params: Dict[str, Any]) -> ToolResult:
229
+ body = self._body(tool, request, params)
230
+ return self._result(self._send("POST", self._url(tool), body, auth=True))
231
+
232
+ def call(self, tool: str, params: Mapping[str, Any]) -> ToolResult:
233
+ """Run any tool by name with a raw body. Nothing is validated locally."""
234
+ return self._result(self._send("POST", self._url(tool), dict(params), auth=True))
235
+
236
+ def describe(self, tool: str) -> ToolInfo:
237
+ """The tool's self-description (GET, no API key sent)."""
238
+ return self._info(self._send("GET", self._url(tool), None, auth=False))
239
+
240
+
241
+ class AsyncCrawlForge(_Core, AsyncToolsMixin):
242
+ """Asynchronous client for the CrawlForge REST API; same options as ``CrawlForge``."""
243
+
244
+ def __init__(
245
+ self,
246
+ api_key: Optional[str] = None,
247
+ base_url: Optional[str] = None,
248
+ max_retries: int = DEFAULT_MAX_RETRIES,
249
+ timeout: float = DEFAULT_TIMEOUT,
250
+ http_client: Optional[httpx.AsyncClient] = None,
251
+ ) -> None:
252
+ super().__init__(api_key, base_url, max_retries, timeout)
253
+ self._http = http_client if http_client is not None else httpx.AsyncClient()
254
+ self._owns_http = http_client is None
255
+
256
+ async def aclose(self) -> None:
257
+ if self._owns_http:
258
+ await self._http.aclose()
259
+
260
+ async def __aenter__(self) -> AsyncCrawlForge:
261
+ return self
262
+
263
+ async def __aexit__(self, *exc: object) -> None:
264
+ await self.aclose()
265
+
266
+ async def _send(
267
+ self, method: str, url: str, body: Optional[Dict[str, Any]], *, auth: bool
268
+ ) -> httpx.Response:
269
+ attempt = 0
270
+ while True:
271
+ try:
272
+ response = await self._http.request(
273
+ method,
274
+ url,
275
+ json=body,
276
+ headers=self._headers(auth=auth, body=body is not None),
277
+ timeout=self.timeout,
278
+ )
279
+ except httpx.TransportError as exc:
280
+ raise self._network_error(exc) from exc
281
+ if response.status_code == 429 and attempt < self.max_retries:
282
+ await _async_sleep(retry_wait(parse_retry_after(response.headers), attempt))
283
+ attempt += 1
284
+ continue
285
+ return response
286
+
287
+ async def _run_tool(self, tool: str, request: Any, params: Dict[str, Any]) -> ToolResult:
288
+ body = self._body(tool, request, params)
289
+ return self._result(await self._send("POST", self._url(tool), body, auth=True))
290
+
291
+ async def call(self, tool: str, params: Mapping[str, Any]) -> ToolResult:
292
+ """Run any tool by name with a raw body. Nothing is validated locally."""
293
+ return self._result(await self._send("POST", self._url(tool), dict(params), auth=True))
294
+
295
+ async def describe(self, tool: str) -> ToolInfo:
296
+ """The tool's self-description (GET, no API key sent)."""
297
+ return self._info(await self._send("GET", self._url(tool), None, auth=False))
298
+
299
+
300
+ __all__ = ["CrawlForge", "AsyncCrawlForge", "BaseModel", "retry_wait"]
@@ -0,0 +1 @@
1
+ """Code generated from openapi.json by scripts/generate.py. Do not edit by hand."""