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 +38 -0
- crawlforge/_client.py +300 -0
- crawlforge/_generated/__init__.py +1 -0
- crawlforge/_generated/models.py +665 -0
- crawlforge/_generated/tools.py +2246 -0
- crawlforge/_version.py +10 -0
- crawlforge/errors.py +193 -0
- crawlforge/models.py +9 -0
- crawlforge/py.typed +0 -0
- crawlforge/types.py +34 -0
- crawlforge-0.1.0.dist-info/METADATA +269 -0
- crawlforge-0.1.0.dist-info/RECORD +14 -0
- crawlforge-0.1.0.dist-info/WHEEL +4 -0
- crawlforge-0.1.0.dist-info/licenses/LICENSE +21 -0
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."""
|