contextfetch 0.1.0__tar.gz

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.
@@ -0,0 +1,20 @@
1
+ .venv/
2
+ node_modules/
3
+ .next/
4
+ dist/
5
+ engine/.model_cache/
6
+ engine/.data/
7
+ .env
8
+ .env.npm
9
+ .env.webshare
10
+ .venv
11
+ engine/.model_cache
12
+ growth-sniper/leads.csv
13
+ __pycache__/
14
+ .pytest_cache/
15
+ .env.hetzner
16
+ .next-*/
17
+ .vercel/
18
+ benchmarks/data/
19
+ *.tsbuildinfo
20
+ next-env.d.ts
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ContextFetch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.5
2
+ Name: contextfetch
3
+ Version: 0.1.0
4
+ Summary: Python client for the ContextFetch API: web search and web pages as Markdown for AI agents.
5
+ Project-URL: Homepage, https://contextfetch.dev
6
+ Project-URL: Documentation, https://contextfetch.dev/docs/reference
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: ai-agents,contextfetch,llm,markdown,rag,search,web-search
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+
19
+ # contextfetch
20
+
21
+ Python client for the [ContextFetch](https://contextfetch.dev) API: web search, pages as Markdown, extractive answers, research, crawl and site maps for AI agents.
22
+
23
+ - No dependencies: standard library only (`urllib`, `json`).
24
+ - Typed: ships `py.typed`; responses are `TypedDict`s.
25
+ - Python 3.9 or newer.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install contextfetch
31
+ ```
32
+
33
+ ## Quickstart
34
+
35
+ ```python
36
+ from contextfetch import ContextFetch
37
+
38
+ client = ContextFetch() # reads CONTEXTFETCH_API_KEY; with no key it uses the keyless tier
39
+
40
+ res = client.search("python asyncio timeout", num_results=5, highlights=True, pruned_context=True)
41
+ for r in res["results"]:
42
+ print(r["url"], r.get("title"))
43
+ print(r.get("prunedContext", "")[:300])
44
+
45
+ page = client.read("https://docs.python.org/3/library/asyncio-task.html", query="task groups")
46
+ print(page.markdown)
47
+ ```
48
+
49
+ No key yet? Get a free starter key (one per network, no signup):
50
+
51
+ ```python
52
+ key = ContextFetch().free_key()["apiKey"]
53
+ client = ContextFetch(key)
54
+ ```
55
+
56
+ ## Methods
57
+
58
+ | Method | Endpoint |
59
+ |---|---|
60
+ | `search(query, *, num_results, include_domains, exclude_domains, start_published_date, text, highlights, pruned_context)` | `POST /v1/search` |
61
+ | `find_similar(url, *, ...same options as search)` | `POST /v1/findSimilar` |
62
+ | `contents(urls, *, text=True, highlights, pruned_context)` | `POST /v1/contents` |
63
+ | `answer(query, *, num_results, text)` | `POST /v1/answer` (extractive: passages quoted verbatim with `[n]` citations) |
64
+ | `research(query, *, sub_queries, num_results_per_query)` | `POST /v1/research` |
65
+ | `crawl(url, *, limit, max_depth, max_breadth, query, include_paths, exclude_paths)` | `POST /v1/crawl` |
66
+ | `map(url, *, limit, include_paths, exclude_paths)` | `POST /v1/map` |
67
+ | `read(url, *, query)` | `GET /r/{url}`, returns a `Page` (`markdown`, `raw_tokens`, `output_tokens`, `token_savings`) |
68
+ | `balance()` | `GET /v1/balance` |
69
+ | `free_key()` | `POST /v1/keys/free` |
70
+
71
+ `text`, `highlights` and `pruned_context` take `True` or an options object as described in the [API reference](https://contextfetch.dev/docs/reference), for example `highlights={"query": "task groups", "highlightsPerUrl": 2}`. Responses are the API's JSON as dicts, with the API's field names (`publishedDate`, `prunedContext`, ...).
72
+
73
+ Options: `ContextFetch(api_key=None, *, base_url="https://api.contextfetch.dev", timeout=60.0)`.
74
+
75
+ ## Errors
76
+
77
+ Every error is a `ContextFetchError` with `status`, `code` (the body's `error` field) and `body`.
78
+
79
+ - `AuthenticationError`: HTTP 401, the key is not recognised.
80
+ - `PaymentRequiredError`: HTTP 402, credits or the keyless allowance ran out. `payment_url` is a one-click checkout link and `prompt_to_user` is a sentence to show your user; stop retrying until they pay.
81
+ - `APIConnectionError`: no response (DNS, connection or timeout).
82
+
83
+ ```python
84
+ from contextfetch import ContextFetch, PaymentRequiredError
85
+
86
+ try:
87
+ ContextFetch().search("rust async runtimes")
88
+ except PaymentRequiredError as e:
89
+ print(e.prompt_to_user) # includes e.payment_url
90
+ ```
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ python -m unittest discover tests # unit tests, local fake server
96
+ CONTEXTFETCH_API_KEY=... python -m unittest tests.test_live -v # live smoke test, a few credits
97
+ ```
98
+
99
+ ## License
100
+
101
+ MIT
@@ -0,0 +1,83 @@
1
+ # contextfetch
2
+
3
+ Python client for the [ContextFetch](https://contextfetch.dev) API: web search, pages as Markdown, extractive answers, research, crawl and site maps for AI agents.
4
+
5
+ - No dependencies: standard library only (`urllib`, `json`).
6
+ - Typed: ships `py.typed`; responses are `TypedDict`s.
7
+ - Python 3.9 or newer.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install contextfetch
13
+ ```
14
+
15
+ ## Quickstart
16
+
17
+ ```python
18
+ from contextfetch import ContextFetch
19
+
20
+ client = ContextFetch() # reads CONTEXTFETCH_API_KEY; with no key it uses the keyless tier
21
+
22
+ res = client.search("python asyncio timeout", num_results=5, highlights=True, pruned_context=True)
23
+ for r in res["results"]:
24
+ print(r["url"], r.get("title"))
25
+ print(r.get("prunedContext", "")[:300])
26
+
27
+ page = client.read("https://docs.python.org/3/library/asyncio-task.html", query="task groups")
28
+ print(page.markdown)
29
+ ```
30
+
31
+ No key yet? Get a free starter key (one per network, no signup):
32
+
33
+ ```python
34
+ key = ContextFetch().free_key()["apiKey"]
35
+ client = ContextFetch(key)
36
+ ```
37
+
38
+ ## Methods
39
+
40
+ | Method | Endpoint |
41
+ |---|---|
42
+ | `search(query, *, num_results, include_domains, exclude_domains, start_published_date, text, highlights, pruned_context)` | `POST /v1/search` |
43
+ | `find_similar(url, *, ...same options as search)` | `POST /v1/findSimilar` |
44
+ | `contents(urls, *, text=True, highlights, pruned_context)` | `POST /v1/contents` |
45
+ | `answer(query, *, num_results, text)` | `POST /v1/answer` (extractive: passages quoted verbatim with `[n]` citations) |
46
+ | `research(query, *, sub_queries, num_results_per_query)` | `POST /v1/research` |
47
+ | `crawl(url, *, limit, max_depth, max_breadth, query, include_paths, exclude_paths)` | `POST /v1/crawl` |
48
+ | `map(url, *, limit, include_paths, exclude_paths)` | `POST /v1/map` |
49
+ | `read(url, *, query)` | `GET /r/{url}`, returns a `Page` (`markdown`, `raw_tokens`, `output_tokens`, `token_savings`) |
50
+ | `balance()` | `GET /v1/balance` |
51
+ | `free_key()` | `POST /v1/keys/free` |
52
+
53
+ `text`, `highlights` and `pruned_context` take `True` or an options object as described in the [API reference](https://contextfetch.dev/docs/reference), for example `highlights={"query": "task groups", "highlightsPerUrl": 2}`. Responses are the API's JSON as dicts, with the API's field names (`publishedDate`, `prunedContext`, ...).
54
+
55
+ Options: `ContextFetch(api_key=None, *, base_url="https://api.contextfetch.dev", timeout=60.0)`.
56
+
57
+ ## Errors
58
+
59
+ Every error is a `ContextFetchError` with `status`, `code` (the body's `error` field) and `body`.
60
+
61
+ - `AuthenticationError`: HTTP 401, the key is not recognised.
62
+ - `PaymentRequiredError`: HTTP 402, credits or the keyless allowance ran out. `payment_url` is a one-click checkout link and `prompt_to_user` is a sentence to show your user; stop retrying until they pay.
63
+ - `APIConnectionError`: no response (DNS, connection or timeout).
64
+
65
+ ```python
66
+ from contextfetch import ContextFetch, PaymentRequiredError
67
+
68
+ try:
69
+ ContextFetch().search("rust async runtimes")
70
+ except PaymentRequiredError as e:
71
+ print(e.prompt_to_user) # includes e.payment_url
72
+ ```
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ python -m unittest discover tests # unit tests, local fake server
78
+ CONTEXTFETCH_API_KEY=... python -m unittest tests.test_live -v # live smoke test, a few credits
79
+ ```
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,404 @@
1
+ """ContextFetch: web search and web pages as Markdown for AI agents.
2
+
3
+ A thin client for https://api.contextfetch.dev that uses only the Python standard library.
4
+ API reference: https://contextfetch.dev/docs/reference
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import urllib.error
10
+ import urllib.parse
11
+ import urllib.request
12
+ from dataclasses import dataclass
13
+ from email.message import Message
14
+ from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = [
19
+ "APIConnectionError",
20
+ "AnswerResponse",
21
+ "AuthenticationError",
22
+ "Balance",
23
+ "ContentsResponse",
24
+ "ContextFetch",
25
+ "ContextFetchError",
26
+ "CrawlResponse",
27
+ "DEFAULT_BASE_URL",
28
+ "FreeKey",
29
+ "MapResponse",
30
+ "Page",
31
+ "PaymentRequiredError",
32
+ "ResearchResponse",
33
+ "Result",
34
+ "SearchResponse",
35
+ ]
36
+
37
+ DEFAULT_BASE_URL = "https://api.contextfetch.dev"
38
+
39
+ # A contents option: True, or an options object as the API reference describes it,
40
+ # e.g. highlights={"query": "task groups", "highlightsPerUrl": 2}.
41
+ Option = Union[bool, Dict[str, Any]]
42
+
43
+
44
+ # ---- responses (JSON as the API returns it; every key is optional) ----
45
+
46
+
47
+ class Result(TypedDict, total=False):
48
+ id: str
49
+ url: str
50
+ title: Optional[str]
51
+ score: Optional[float]
52
+ publishedDate: Optional[str]
53
+ author: Optional[str]
54
+ text: str # the page as Markdown
55
+ highlights: List[str]
56
+ highlightScores: List[float]
57
+ prunedContext: str
58
+ subQueries: List[str] # research: the sub-queries that found this page
59
+ depth: int # crawl: link hops from the start URL
60
+
61
+
62
+ class SearchResponse(TypedDict, total=False):
63
+ requestId: str
64
+ results: List[Result]
65
+ costDollars: Dict[str, float]
66
+ searchTimeMs: float
67
+ contextfetch: Dict[str, Any] # cache, timingsMs, tokens, sources
68
+ notice: str
69
+
70
+
71
+ class ContentsResponse(SearchResponse, total=False):
72
+ statuses: List[Dict[str, Any]] # one per URL: {"id", "status": "success" | "error", "error"?}
73
+
74
+
75
+ class ResearchResponse(SearchResponse, total=False):
76
+ query: str
77
+ subQueries: List[Dict[str, Any]] # {"query", "status", "results"}
78
+ evidence: str # one Markdown document with numbered [n] sources quoted verbatim
79
+
80
+
81
+ class CrawlResponse(SearchResponse, total=False):
82
+ baseUrl: str
83
+
84
+
85
+ class Citation(TypedDict, total=False):
86
+ id: str
87
+ url: str
88
+ title: Optional[str]
89
+ publishedDate: Optional[str]
90
+ author: Optional[str]
91
+ text: str
92
+
93
+
94
+ class Passage(TypedDict, total=False):
95
+ text: str
96
+ score: float
97
+ citation: int # 1-based index into citations
98
+
99
+
100
+ class AnswerResponse(TypedDict, total=False):
101
+ answer: str # passages quoted verbatim, each followed by [n]; "" when nothing is relevant enough
102
+ type: str # always "extractive"
103
+ note: str
104
+ citations: List[Citation]
105
+ passages: List[Passage]
106
+ costDollars: Dict[str, float]
107
+ contextfetch: Dict[str, Any]
108
+ notice: str
109
+
110
+
111
+ class MapResponse(TypedDict, total=False):
112
+ requestId: str
113
+ baseUrl: str
114
+ results: List[str]
115
+ costDollars: Dict[str, float]
116
+ contextfetch: Dict[str, Any]
117
+ notice: str
118
+
119
+
120
+ class Balance(TypedDict, total=False):
121
+ credits: int
122
+ auto_reload: Dict[str, Any]
123
+ burn_rate_per_day: float
124
+
125
+
126
+ class FreeKey(TypedDict, total=False):
127
+ apiKey: str
128
+ credits: int
129
+ buy: Dict[str, str] # pack id -> checkout link
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class Page:
134
+ """A page read through GET /r/{url}."""
135
+
136
+ url: str
137
+ markdown: str
138
+ raw_tokens: Optional[int] = None # X-Raw-Tokens
139
+ output_tokens: Optional[int] = None # X-Output-Tokens
140
+ token_savings: Optional[float] = None # X-Token-Savings, percent
141
+
142
+
143
+ # ---- errors ----
144
+
145
+
146
+ class ContextFetchError(Exception):
147
+ """An API or transport error.
148
+
149
+ status is the HTTP status (None when no response arrived), code the body's "error" field
150
+ (e.g. "invalid_api_key", "recall_unavailable") and body the parsed JSON body, if any.
151
+ """
152
+
153
+ def __init__(self, message: str, status: Optional[int] = None, code: Optional[str] = None, body: Any = None) -> None:
154
+ super().__init__(message)
155
+ self.message = message
156
+ self.status = status
157
+ self.code = code
158
+ self.body = body
159
+
160
+
161
+ class AuthenticationError(ContextFetchError):
162
+ """HTTP 401: the API key is not recognised."""
163
+
164
+
165
+ class PaymentRequiredError(ContextFetchError):
166
+ """HTTP 402: the key's credits or the keyless daily allowance ran out.
167
+
168
+ Show prompt_to_user to your human and stop retrying. payment_url (the X-Payment-URL header)
169
+ opens Stripe Checkout in one click; packs lists every pack with its own checkout_url.
170
+ """
171
+
172
+ def __init__(
173
+ self,
174
+ message: str,
175
+ status: Optional[int] = None,
176
+ code: Optional[str] = None,
177
+ body: Any = None,
178
+ payment_url: Optional[str] = None,
179
+ prompt_to_user: Optional[str] = None,
180
+ ) -> None:
181
+ super().__init__(message, status, code, body)
182
+ self.payment_url = payment_url
183
+ self.prompt_to_user = prompt_to_user
184
+ self.packs: List[Dict[str, Any]] = (body.get("packs") or []) if isinstance(body, dict) else []
185
+
186
+
187
+ class APIConnectionError(ContextFetchError):
188
+ """No HTTP response: DNS, connection or timeout failure."""
189
+
190
+
191
+ # ---- client ----
192
+
193
+
194
+ class ContextFetch:
195
+ """Client for the ContextFetch API.
196
+
197
+ api_key defaults to the CONTEXTFETCH_API_KEY environment variable. With no key the client uses the
198
+ keyless tier (a small daily allowance per IP); free_key() returns a starter key with no signup.
199
+ """
200
+
201
+ def __init__(self, api_key: Optional[str] = None, *, base_url: str = DEFAULT_BASE_URL, timeout: float = 60.0) -> None:
202
+ self.api_key = (api_key if api_key is not None else os.environ.get("CONTEXTFETCH_API_KEY")) or None
203
+ self.base_url = base_url.rstrip("/")
204
+ self.timeout = timeout
205
+
206
+ def search(
207
+ self,
208
+ query: str,
209
+ *,
210
+ num_results: Optional[int] = None,
211
+ include_domains: Optional[List[str]] = None,
212
+ exclude_domains: Optional[List[str]] = None,
213
+ start_published_date: Optional[str] = None,
214
+ text: Option = False,
215
+ highlights: Option = False,
216
+ pruned_context: Option = False,
217
+ ) -> SearchResponse:
218
+ """POST /v1/search. Results carry page text, highlights or prunedContext only when asked for."""
219
+ body = {
220
+ "query": query,
221
+ "numResults": num_results,
222
+ "includeDomains": include_domains,
223
+ "excludeDomains": exclude_domains,
224
+ "startPublishedDate": start_published_date,
225
+ "contents": _contents(text, highlights, pruned_context),
226
+ }
227
+ return self._post("/v1/search", body) # type: ignore[return-value]
228
+
229
+ def find_similar(
230
+ self,
231
+ url: str,
232
+ *,
233
+ num_results: Optional[int] = None,
234
+ include_domains: Optional[List[str]] = None,
235
+ exclude_domains: Optional[List[str]] = None,
236
+ start_published_date: Optional[str] = None,
237
+ text: Option = False,
238
+ highlights: Option = False,
239
+ pruned_context: Option = False,
240
+ ) -> SearchResponse:
241
+ """POST /v1/findSimilar: pages like url, from other domains. Same shape as search()."""
242
+ body = {
243
+ "url": url,
244
+ "numResults": num_results,
245
+ "includeDomains": include_domains,
246
+ "excludeDomains": exclude_domains,
247
+ "startPublishedDate": start_published_date,
248
+ "contents": _contents(text, highlights, pruned_context),
249
+ }
250
+ return self._post("/v1/findSimilar", body) # type: ignore[return-value]
251
+
252
+ def contents(
253
+ self,
254
+ urls: List[str],
255
+ *,
256
+ text: Option = True,
257
+ highlights: Option = False,
258
+ pruned_context: Option = False,
259
+ ) -> ContentsResponse:
260
+ """POST /v1/contents: pages as Markdown. URLs that fail come back in statuses and are not billed."""
261
+ body = {"urls": list(urls), "text": text, "highlights": highlights or None, "pruned_context": pruned_context or None}
262
+ return self._post("/v1/contents", body) # type: ignore[return-value]
263
+
264
+ def answer(self, query: str, *, num_results: Optional[int] = None, text: bool = False) -> AnswerResponse:
265
+ """POST /v1/answer: an extractive answer, passages quoted verbatim with [n] citations. Nothing is generated."""
266
+ return self._post("/v1/answer", {"query": query, "numResults": num_results, "text": text or None}) # type: ignore[return-value]
267
+
268
+ def research(
269
+ self,
270
+ query: str,
271
+ *,
272
+ sub_queries: Optional[List[str]] = None,
273
+ num_results_per_query: Optional[int] = None,
274
+ ) -> ResearchResponse:
275
+ """POST /v1/research: several searches in one call, ranked together into an evidence pack."""
276
+ body = {"query": query, "subQueries": sub_queries, "numResultsPerQuery": num_results_per_query}
277
+ return self._post("/v1/research", body) # type: ignore[return-value]
278
+
279
+ def crawl(
280
+ self,
281
+ url: str,
282
+ *,
283
+ limit: Optional[int] = None,
284
+ max_depth: Optional[int] = None,
285
+ max_breadth: Optional[int] = None,
286
+ query: Optional[str] = None,
287
+ include_paths: Optional[List[str]] = None,
288
+ exclude_paths: Optional[List[str]] = None,
289
+ ) -> CrawlResponse:
290
+ """POST /v1/crawl: one site from a start URL, every page as Markdown. robots.txt is obeyed."""
291
+ body = {
292
+ "url": url,
293
+ "limit": limit,
294
+ "maxDepth": max_depth,
295
+ "maxBreadth": max_breadth,
296
+ "query": query,
297
+ "includePaths": include_paths,
298
+ "excludePaths": exclude_paths,
299
+ }
300
+ return self._post("/v1/crawl", body) # type: ignore[return-value]
301
+
302
+ def map(
303
+ self,
304
+ url: str,
305
+ *,
306
+ limit: Optional[int] = None,
307
+ include_paths: Optional[List[str]] = None,
308
+ exclude_paths: Optional[List[str]] = None,
309
+ ) -> MapResponse:
310
+ """POST /v1/map: a site's URLs from its links and sitemaps, without fetching the pages."""
311
+ body = {"url": url, "limit": limit, "includePaths": include_paths, "excludePaths": exclude_paths}
312
+ return self._post("/v1/map", body) # type: ignore[return-value]
313
+
314
+ def read(self, url: str, *, query: Optional[str] = None) -> Page:
315
+ """GET /r/{url}: any public URL as Markdown. With query, only the chunks relevant to it."""
316
+ target = urllib.parse.urldefrag(url)[0]
317
+ path = "/r/" + urllib.parse.quote(target, safe=":/?&=%@+,;~!$'()*[]")
318
+ if query:
319
+ path += ("&" if "?" in target else "?") + urllib.parse.urlencode({"q": query})
320
+ raw, headers = self._request("GET", path)
321
+ return Page(
322
+ url=target,
323
+ markdown=raw.decode("utf-8", "replace"),
324
+ raw_tokens=_number(headers.get("X-Raw-Tokens"), int),
325
+ output_tokens=_number(headers.get("X-Output-Tokens"), int),
326
+ token_savings=_number(headers.get("X-Token-Savings"), float),
327
+ )
328
+
329
+ def balance(self) -> Balance:
330
+ """GET /v1/balance: credits left on this key and its auto-reload settings. Free."""
331
+ return _json(self._request("GET", "/v1/balance")[0]) # type: ignore[return-value]
332
+
333
+ def free_key(self) -> FreeKey:
334
+ """POST /v1/keys/free: a free starter key, one per network. Sent without this client's key."""
335
+ return _json(self._request("POST", "/v1/keys/free", auth=False)[0]) # type: ignore[return-value]
336
+
337
+ # ---- transport ----
338
+
339
+ def _post(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
340
+ return _json(self._request("POST", path, {k: v for k, v in body.items() if v is not None})[0])
341
+
342
+ def _request(
343
+ self, method: str, path: str, body: Optional[Dict[str, Any]] = None, auth: bool = True
344
+ ) -> Tuple[bytes, Message]:
345
+ headers = {"User-Agent": f"contextfetch-python/{__version__}"}
346
+ data = None
347
+ if body is not None:
348
+ data = json.dumps(body).encode("utf-8")
349
+ headers["Content-Type"] = "application/json"
350
+ if auth and self.api_key:
351
+ headers["Authorization"] = f"Bearer {self.api_key}"
352
+ req = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method)
353
+ try:
354
+ with urllib.request.urlopen(req, timeout=self.timeout) as res:
355
+ return res.read(), res.headers
356
+ except urllib.error.HTTPError as e:
357
+ raise _api_error(e.code, e.read(), e.headers) from None
358
+ except (urllib.error.URLError, OSError) as e: # OSError covers timeouts
359
+ reason = getattr(e, "reason", None) or e
360
+ raise APIConnectionError(f"Could not reach the ContextFetch API at {self.base_url}: {reason}") from e
361
+
362
+
363
+ def _contents(text: Option, highlights: Option, pruned_context: Option) -> Optional[Dict[str, Option]]:
364
+ wanted = {"text": text, "highlights": highlights, "pruned_context": pruned_context}
365
+ return {k: v for k, v in wanted.items() if v} or None
366
+
367
+
368
+ def _json(raw: bytes) -> Dict[str, Any]:
369
+ try:
370
+ data = json.loads(raw)
371
+ except ValueError:
372
+ data = None
373
+ if not isinstance(data, dict):
374
+ raise ContextFetchError(f"The ContextFetch API returned an unreadable response: {raw[:200]!r}")
375
+ return data
376
+
377
+
378
+ def _number(value: Optional[str], kind: Any) -> Any:
379
+ try:
380
+ return kind(value) if value is not None else None
381
+ except ValueError:
382
+ return None
383
+
384
+
385
+ def _api_error(status: int, raw: bytes, headers: Message) -> ContextFetchError:
386
+ try:
387
+ body: Any = json.loads(raw)
388
+ except ValueError:
389
+ body = None
390
+ info: Dict[str, Any] = body if isinstance(body, dict) else {}
391
+ code = info.get("error") if isinstance(info.get("error"), str) else None
392
+ detail = info.get("message") or info.get("detail") or code or raw.decode("utf-8", "replace")[:300]
393
+ if not isinstance(detail, str):
394
+ detail = json.dumps(detail)[:300]
395
+ message = f"ContextFetch API returned HTTP {status}" + (f" ({code})" if code else "") + (f": {detail}" if detail and detail != code else "")
396
+ if status == 401:
397
+ return AuthenticationError(message, status, code, body)
398
+ if status == 402:
399
+ action: Dict[str, Any] = info["action"] if isinstance(info.get("action"), dict) else {}
400
+ prompt = action.get("prompt_to_user")
401
+ if prompt and prompt not in message:
402
+ message += f" {prompt}"
403
+ return PaymentRequiredError(message, status, code, body, headers.get("X-Payment-URL") or action.get("url"), prompt)
404
+ return ContextFetchError(message, status, code, body)
File without changes
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "contextfetch"
7
+ version = "0.1.0"
8
+ description = "Python client for the ContextFetch API: web search and web pages as Markdown for AI agents."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ dependencies = []
13
+ keywords = ["search", "web-search", "markdown", "ai-agents", "llm", "rag", "contextfetch"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
20
+ "Typing :: Typed",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://contextfetch.dev"
25
+ Documentation = "https://contextfetch.dev/docs/reference"
26
+
27
+ [tool.hatch.build.targets.sdist]
28
+ include = ["contextfetch", "tests", "README.md", "LICENSE"]
@@ -0,0 +1,204 @@
1
+ """Unit tests against a local fake of the API (stdlib http.server). No network."""
2
+
3
+ import json
4
+ import os
5
+ import socket
6
+ import threading
7
+ import unittest
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from unittest import mock
10
+
11
+ from contextfetch import (
12
+ APIConnectionError,
13
+ AuthenticationError,
14
+ ContextFetch,
15
+ ContextFetchError,
16
+ Page,
17
+ PaymentRequiredError,
18
+ )
19
+
20
+ PAY_URL = "https://api.contextfetch.dev/buy/instant"
21
+ PAYMENT_BODY = {
22
+ "error": "insufficient_credits",
23
+ "message": "This key is out of credits.",
24
+ "checkout_url": "https://contextfetch.dev/#buy",
25
+ "packs": [{"id": "instant", "credits": 1000, "checkout_url": PAY_URL}],
26
+ "action": {"type": "payment_link", "url": PAY_URL, "prompt_to_user": f"ContextFetch credits ran out: {PAY_URL}"},
27
+ }
28
+
29
+
30
+ class Handler(BaseHTTPRequestHandler):
31
+ def handle_any(self) -> None:
32
+ length = int(self.headers.get("Content-Length") or 0)
33
+ raw = self.rfile.read(length) if length else b""
34
+ self.server.requests.append( # type: ignore[attr-defined]
35
+ {"method": self.command, "path": self.path, "headers": self.headers, "body": json.loads(raw) if raw else None}
36
+ )
37
+ status, headers, payload = self.server.reply # type: ignore[attr-defined]
38
+ data = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
39
+ self.send_response(status)
40
+ for k, v in headers.items():
41
+ self.send_header(k, v)
42
+ self.send_header("Content-Length", str(len(data)))
43
+ self.end_headers()
44
+ self.wfile.write(data)
45
+
46
+ do_GET = do_POST = handle_any
47
+
48
+ def log_message(self, *args: object) -> None:
49
+ pass
50
+
51
+
52
+ class ClientTest(unittest.TestCase):
53
+ @classmethod
54
+ def setUpClass(cls) -> None:
55
+ cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
56
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
57
+ cls.base = f"http://127.0.0.1:{cls.server.server_address[1]}"
58
+
59
+ @classmethod
60
+ def tearDownClass(cls) -> None:
61
+ cls.server.shutdown()
62
+ cls.server.server_close()
63
+
64
+ def setUp(self) -> None:
65
+ self.server.requests = []
66
+ self.server.reply = (200, {"Content-Type": "application/json"}, {"results": []})
67
+ self.client = ContextFetch("cf_live_test", base_url=self.base + "/")
68
+
69
+ @property
70
+ def last(self) -> dict:
71
+ return self.server.requests[-1]
72
+
73
+ def test_search_sends_key_and_camel_case_body(self) -> None:
74
+ self.server.reply = (200, {}, {"requestId": "r1", "results": [{"url": "https://a.example", "text": "# A"}]})
75
+ out = self.client.search("vector databases", num_results=3, exclude_domains=["pinterest.com"], text=True, pruned_context=True)
76
+ self.assertEqual(out["results"][0]["text"], "# A")
77
+ self.assertEqual((self.last["method"], self.last["path"]), ("POST", "/v1/search"))
78
+ self.assertEqual(self.last["headers"]["Authorization"], "Bearer cf_live_test")
79
+ self.assertTrue(self.last["headers"]["User-Agent"].startswith("contextfetch-python/"))
80
+ self.assertEqual(
81
+ self.last["body"],
82
+ {"query": "vector databases", "numResults": 3, "excludeDomains": ["pinterest.com"], "contents": {"text": True, "pruned_context": True}},
83
+ )
84
+
85
+ def test_search_without_contents_omits_it(self) -> None:
86
+ self.client.search("q")
87
+ self.assertEqual(self.last["body"], {"query": "q"})
88
+
89
+ def test_endpoint_bodies(self) -> None:
90
+ cases = [
91
+ (lambda c: c.find_similar("https://a.example", num_results=2, highlights={"query": "x"}), "/v1/findSimilar",
92
+ {"url": "https://a.example", "numResults": 2, "contents": {"highlights": {"query": "x"}}}),
93
+ (lambda c: c.contents(["https://a.example"]), "/v1/contents", {"urls": ["https://a.example"], "text": True}),
94
+ (lambda c: c.contents(("https://a.example",), text=False, highlights=True), "/v1/contents",
95
+ {"urls": ["https://a.example"], "text": False, "highlights": True}),
96
+ (lambda c: c.answer("why?", num_results=4, text=True), "/v1/answer", {"query": "why?", "numResults": 4, "text": True}),
97
+ (lambda c: c.research("topic", sub_queries=["a", "b"], num_results_per_query=2), "/v1/research",
98
+ {"query": "topic", "subQueries": ["a", "b"], "numResultsPerQuery": 2}),
99
+ (lambda c: c.crawl("https://a.example", limit=5, max_depth=2, query="t", include_paths=["/docs"]), "/v1/crawl",
100
+ {"url": "https://a.example", "limit": 5, "maxDepth": 2, "query": "t", "includePaths": ["/docs"]}),
101
+ (lambda c: c.map("https://a.example", limit=50, exclude_paths=["/blog"]), "/v1/map",
102
+ {"url": "https://a.example", "limit": 50, "excludePaths": ["/blog"]}),
103
+ ]
104
+ for call, path, body in cases:
105
+ with self.subTest(path=path):
106
+ call(self.client)
107
+ self.assertEqual((self.last["method"], self.last["path"], self.last["body"]), ("POST", path, body))
108
+
109
+ def test_balance_is_a_get_with_the_key(self) -> None:
110
+ self.server.reply = (200, {}, {"credits": 2499, "auto_reload": {"enabled": False}, "burn_rate_per_day": 1})
111
+ self.assertEqual(self.client.balance()["credits"], 2499)
112
+ self.assertEqual((self.last["method"], self.last["path"], self.last["body"]), ("GET", "/v1/balance", None))
113
+ self.assertEqual(self.last["headers"]["Authorization"], "Bearer cf_live_test")
114
+
115
+ def test_free_key_is_sent_without_a_key(self) -> None:
116
+ self.server.reply = (201, {}, {"apiKey": "cf_live_" + "0" * 32, "credits": 2500})
117
+ self.assertEqual(self.client.free_key()["credits"], 2500)
118
+ self.assertEqual((self.last["method"], self.last["path"]), ("POST", "/v1/keys/free"))
119
+ self.assertIsNone(self.last["headers"].get("Authorization"))
120
+
121
+ def test_keyless_when_no_key_anywhere(self) -> None:
122
+ with mock.patch.dict(os.environ, {}, clear=False):
123
+ os.environ.pop("CONTEXTFETCH_API_KEY", None)
124
+ ContextFetch(base_url=self.base).search("q")
125
+ self.assertIsNone(self.last["headers"].get("Authorization"))
126
+
127
+ def test_key_from_environment(self) -> None:
128
+ with mock.patch.dict(os.environ, {"CONTEXTFETCH_API_KEY": "cf_live_env"}):
129
+ ContextFetch(base_url=self.base).search("q")
130
+ self.assertEqual(self.last["headers"]["Authorization"], "Bearer cf_live_env")
131
+
132
+ def test_read_returns_markdown_and_token_headers(self) -> None:
133
+ headers = {"Content-Type": "text/markdown; charset=utf-8", "X-Token-Savings": "91.0", "X-Raw-Tokens": "18240", "X-Output-Tokens": "1650"}
134
+ self.server.reply = (200, headers, "## Task Groups\n\nÜnïcode".encode())
135
+ page = self.client.read("https://docs.python.org/3/library/asyncio-task.html#frag", query="task groups")
136
+ self.assertEqual(page, Page("https://docs.python.org/3/library/asyncio-task.html", "## Task Groups\n\nÜnïcode", 18240, 1650, 91.0))
137
+ self.assertEqual(self.last["path"], "/r/https://docs.python.org/3/library/asyncio-task.html?q=task+groups")
138
+
139
+ def test_read_keeps_the_target_query_and_encodes_spaces(self) -> None:
140
+ self.server.reply = (200, {}, b"x")
141
+ page = self.client.read("https://a.example/a b?id=1", query="q")
142
+ self.assertEqual(self.last["path"], "/r/https://a.example/a%20b?id=1&q=q")
143
+ self.assertIsNone(page.raw_tokens)
144
+
145
+ def test_402_exposes_payment_link(self) -> None:
146
+ self.server.reply = (402, {"X-Payment-URL": PAY_URL}, PAYMENT_BODY)
147
+ with self.assertRaises(PaymentRequiredError) as ctx:
148
+ self.client.search("q")
149
+ e = ctx.exception
150
+ self.assertEqual((e.status, e.code, e.payment_url), (402, "insufficient_credits", PAY_URL))
151
+ self.assertEqual(e.prompt_to_user, PAYMENT_BODY["action"]["prompt_to_user"])
152
+ self.assertEqual(e.packs[0]["id"], "instant")
153
+ self.assertIn(PAY_URL, str(e))
154
+
155
+ def test_402_without_header_falls_back_to_action_url(self) -> None:
156
+ self.server.reply = (402, {}, PAYMENT_BODY)
157
+ with self.assertRaises(PaymentRequiredError) as ctx:
158
+ self.client.read("https://a.example")
159
+ self.assertEqual(ctx.exception.payment_url, PAY_URL)
160
+
161
+ def test_401_is_authentication_error(self) -> None:
162
+ self.server.reply = (401, {}, {"error": "invalid_api_key"})
163
+ with self.assertRaises(AuthenticationError) as ctx:
164
+ self.client.balance()
165
+ self.assertEqual((ctx.exception.status, ctx.exception.code), (401, "invalid_api_key"))
166
+
167
+ def test_other_errors_carry_status_code_and_message(self) -> None:
168
+ self.server.reply = (503, {}, {"error": "recall_unavailable", "message": "Web search backends are down."})
169
+ with self.assertRaises(ContextFetchError) as ctx:
170
+ self.client.search("q")
171
+ e = ctx.exception
172
+ self.assertNotIsInstance(e, PaymentRequiredError)
173
+ self.assertEqual((e.status, e.code), (503, "recall_unavailable"))
174
+ self.assertIn("Web search backends are down.", str(e))
175
+
176
+ def test_validation_error_detail_is_in_the_message(self) -> None:
177
+ self.server.reply = (422, {}, {"detail": [{"loc": ["body", "query"], "msg": "too long"}]})
178
+ with self.assertRaises(ContextFetchError) as ctx:
179
+ self.client.search("q")
180
+ self.assertIn("too long", str(ctx.exception))
181
+
182
+ def test_non_json_error_body(self) -> None:
183
+ self.server.reply = (502, {"Content-Type": "text/html"}, b"<html>Bad Gateway</html>")
184
+ with self.assertRaises(ContextFetchError) as ctx:
185
+ self.client.search("q")
186
+ self.assertEqual((ctx.exception.status, ctx.exception.code, ctx.exception.body), (502, None, None))
187
+ self.assertIn("Bad Gateway", str(ctx.exception))
188
+
189
+ def test_unreadable_success_body(self) -> None:
190
+ self.server.reply = (200, {}, b"not json")
191
+ with self.assertRaises(ContextFetchError):
192
+ self.client.search("q")
193
+
194
+ def test_connection_failure(self) -> None:
195
+ with socket.socket() as s:
196
+ s.bind(("127.0.0.1", 0))
197
+ port = s.getsockname()[1]
198
+ with self.assertRaises(APIConnectionError) as ctx:
199
+ ContextFetch("k", base_url=f"http://127.0.0.1:{port}", timeout=5).search("q")
200
+ self.assertIsNone(ctx.exception.status)
201
+
202
+
203
+ if __name__ == "__main__":
204
+ unittest.main()
@@ -0,0 +1,34 @@
1
+ """Live smoke test against the real API. Runs only when CONTEXTFETCH_API_KEY is set; uses about 3 credits."""
2
+
3
+ import os
4
+ import unittest
5
+
6
+ from contextfetch import ContextFetch
7
+
8
+ ZEN = "https://peps.python.org/pep-0020/"
9
+
10
+
11
+ @unittest.skipUnless(os.environ.get("CONTEXTFETCH_API_KEY"), "CONTEXTFETCH_API_KEY not set")
12
+ class LiveTest(unittest.TestCase):
13
+ def setUp(self) -> None:
14
+ self.client = ContextFetch(base_url=os.environ.get("CONTEXTFETCH_API_BASE", "https://api.contextfetch.dev"))
15
+
16
+ def test_balance(self) -> None:
17
+ self.assertIsInstance(self.client.balance()["credits"], int)
18
+
19
+ def test_search(self) -> None:
20
+ out = self.client.search("python asyncio timeout", num_results=3, highlights=True)
21
+ self.assertGreater(len(out["results"]), 0)
22
+ self.assertTrue(out["results"][0]["url"].startswith("http"))
23
+
24
+ def test_read(self) -> None:
25
+ page = self.client.read(ZEN)
26
+ self.assertIn("Beautiful is better than ugly", page.markdown)
27
+
28
+ def test_contents(self) -> None:
29
+ out = self.client.contents([ZEN])
30
+ self.assertIn("Beautiful is better than ugly", out["results"][0]["text"])
31
+
32
+
33
+ if __name__ == "__main__":
34
+ unittest.main()