blopus 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.
blopus-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Blopus
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.
blopus-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,192 @@
1
+ Metadata-Version: 2.4
2
+ Name: blopus
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Blopus web search + fetch API.
5
+ Author: Blopus
6
+ License: MIT
7
+ Project-URL: Homepage, https://blopus.ai
8
+ Project-URL: Documentation, https://docs.blopus.ai
9
+ Project-URL: Source, https://github.com/blopus-ai/blopus-python
10
+ Keywords: blopus,search,web-search,fetch,api,sdk,agents,mcp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
21
+ Requires-Python: >=3.8
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: httpx>=0.23
25
+ Provides-Extra: langchain
26
+ Requires-Dist: langchain-core>=0.1; extra == "langchain"
27
+ Provides-Extra: llamaindex
28
+ Requires-Dist: llama-index-core>=0.10; extra == "llamaindex"
29
+ Provides-Extra: crewai
30
+ Requires-Dist: crewai>=0.1; extra == "crewai"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=7; extra == "dev"
33
+ Requires-Dist: respx>=0.20; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # blopus — Python SDK
37
+
38
+ Official Python client for the [Blopus](https://blopus.ai) web search + fetch API.
39
+ Blopus is a cheap, fast web-search API backed by an owned index — built for bots
40
+ and agents.
41
+
42
+ The SDK talks to exactly two data-plane endpoints: `POST /v1/search` and
43
+ `POST /v1/fetch` on `https://api.blopus.ai`.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install blopus
49
+ ```
50
+
51
+ Optional framework adapters:
52
+
53
+ ```bash
54
+ pip install "blopus[langchain]" # blopus.langchain.BlopusSearch
55
+ pip install "blopus[llamaindex]" # blopus.llamaindex.BlopusToolSpec
56
+ pip install "blopus[crewai]" # blopus.crewai.BlopusSearchTool
57
+ ```
58
+
59
+ ## Authentication
60
+
61
+ Pass an API key (`blp_live_...`) directly, or set `BLOPUS_API_KEY`:
62
+
63
+ ```bash
64
+ export BLOPUS_API_KEY="blp_live_xxx"
65
+ ```
66
+
67
+ ## Quickstart
68
+
69
+ ```python
70
+ from blopus import Blopus
71
+
72
+ client = Blopus() # reads BLOPUS_API_KEY
73
+
74
+ res = client.search("who won the game last night", count=5, freshness="pd")
75
+ for hit in res:
76
+ print(hit.score, hit.title, hit.url)
77
+ print("remaining quota:", res.remaining_quota)
78
+
79
+ # Fetch indexed page content
80
+ doc = client.fetch("https://example.com/article")
81
+ print(doc.title, len(doc.content))
82
+ ```
83
+
84
+ ### Async
85
+
86
+ ```python
87
+ import asyncio
88
+ from blopus import AsyncBlopus
89
+
90
+ async def main():
91
+ async with AsyncBlopus() as client:
92
+ res = await client.search("openai news", freshness="pw")
93
+ docs = await client.fetch([r.url for r in res]) # batch fetch
94
+ print(docs.count, "fetched,", len(docs.failed_results), "missing")
95
+
96
+ asyncio.run(main())
97
+ ```
98
+
99
+ ## `search(...)`
100
+
101
+ ```python
102
+ client.search(
103
+ query,
104
+ count=10, # 1..50
105
+ freshness="all", # pd | pw | pm | p3m | p1y | all
106
+ include_domains=None, # ["techcrunch.com", ...]
107
+ exclude_domains=None,
108
+ start_date=None, # "YYYY-MM-DD" or epoch seconds
109
+ end_date=None,
110
+ language=None, # "en", "pt", ...
111
+ offset=0, # pagination, up to 200
112
+ include_excerpt=False, # opt in to longer excerpts
113
+ excerpt_chars=None, # up to 1200
114
+ )
115
+ ```
116
+
117
+ Returns a `SearchResponse` (iterable over `SearchResult`):
118
+
119
+ ```python
120
+ res.query # echoed query
121
+ res.results # list[SearchResult]
122
+ res.count # number of results returned
123
+ res.offset
124
+ res.more_results # bool — more pages available
125
+ res.remaining_quota # int — your remaining monthly units
126
+
127
+ # SearchResult fields:
128
+ # title, url, snippet, domain, site_name, favicon,
129
+ # published_at, age_seconds, language, score
130
+ ```
131
+
132
+ Search always costs **1 credit**, regardless of parameters or excerpt size.
133
+
134
+ ## `fetch(url_or_urls)`
135
+
136
+ ```python
137
+ # single URL -> FetchResult
138
+ doc = client.fetch("https://example.com/a")
139
+ doc.url, doc.canonical_url, doc.title, doc.content, doc.domain, doc.published_at, doc.language, doc.found
140
+
141
+ # list of URLs -> BatchFetchResponse
142
+ batch = client.fetch(["https://a.com", "https://b.com"])
143
+ batch.results # list[FetchResult] that were found
144
+ batch.failed_results # list[FetchFailure] (url, found=False)
145
+ batch.count # number found (== credits billed)
146
+ batch.remaining_quota
147
+ ```
148
+
149
+ Batches over the server cap of **50 URLs** are automatically split into ≤50-URL
150
+ calls, run sequentially with a small delay, and merged for you. Fetch bills per
151
+ document found.
152
+
153
+ ## Errors
154
+
155
+ All errors subclass `blopus.BlopusError`:
156
+
157
+ | Exception | When |
158
+ |----------------------|----------------------------------------|
159
+ | `AuthError` | 401 / 403 — bad, missing or revoked key |
160
+ | `QuotaError` | 402 — monthly quota exhausted |
161
+ | `RateLimitError` | 429 — slow down (`.retry_after`) |
162
+ | `NotFoundError` | 404 — no indexed content for a URL |
163
+ | `BadRequestError` | 400 / 413 — malformed / too large |
164
+ | `ServerError` | 5xx — gateway/backend problem |
165
+ | `APIConnectionError` | network failure (no response) |
166
+
167
+ Requests to `429`/`5xx`/connection errors are retried with exponential backoff
168
+ (honoring `Retry-After`); tune with `Blopus(max_retries=...)`.
169
+
170
+ ## MCP
171
+
172
+ Blopus also exposes a hosted MCP server (`search` + `fetch` tools) at
173
+ `https://mcp.blopus.ai`, using the same Bearer auth.
174
+
175
+ ```python
176
+ from blopus import mcp_config, print_mcp_config
177
+ print_mcp_config() # prints the mcpServers JSON to paste into your MCP client
178
+ cfg = mcp_config() # or get it as a dict
179
+ ```
180
+
181
+ ## CLI
182
+
183
+ ```bash
184
+ blopus search "who won the game" --count 5 --freshness pd
185
+ blopus search "openai" --include-domains techcrunch.com,theverge.com --json
186
+ blopus fetch https://example.com/a https://example.com/b
187
+ blopus mcp-config
188
+ ```
189
+
190
+ ## License
191
+
192
+ MIT
blopus-0.1.0/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # blopus — Python SDK
2
+
3
+ Official Python client for the [Blopus](https://blopus.ai) web search + fetch API.
4
+ Blopus is a cheap, fast web-search API backed by an owned index — built for bots
5
+ and agents.
6
+
7
+ The SDK talks to exactly two data-plane endpoints: `POST /v1/search` and
8
+ `POST /v1/fetch` on `https://api.blopus.ai`.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install blopus
14
+ ```
15
+
16
+ Optional framework adapters:
17
+
18
+ ```bash
19
+ pip install "blopus[langchain]" # blopus.langchain.BlopusSearch
20
+ pip install "blopus[llamaindex]" # blopus.llamaindex.BlopusToolSpec
21
+ pip install "blopus[crewai]" # blopus.crewai.BlopusSearchTool
22
+ ```
23
+
24
+ ## Authentication
25
+
26
+ Pass an API key (`blp_live_...`) directly, or set `BLOPUS_API_KEY`:
27
+
28
+ ```bash
29
+ export BLOPUS_API_KEY="blp_live_xxx"
30
+ ```
31
+
32
+ ## Quickstart
33
+
34
+ ```python
35
+ from blopus import Blopus
36
+
37
+ client = Blopus() # reads BLOPUS_API_KEY
38
+
39
+ res = client.search("who won the game last night", count=5, freshness="pd")
40
+ for hit in res:
41
+ print(hit.score, hit.title, hit.url)
42
+ print("remaining quota:", res.remaining_quota)
43
+
44
+ # Fetch indexed page content
45
+ doc = client.fetch("https://example.com/article")
46
+ print(doc.title, len(doc.content))
47
+ ```
48
+
49
+ ### Async
50
+
51
+ ```python
52
+ import asyncio
53
+ from blopus import AsyncBlopus
54
+
55
+ async def main():
56
+ async with AsyncBlopus() as client:
57
+ res = await client.search("openai news", freshness="pw")
58
+ docs = await client.fetch([r.url for r in res]) # batch fetch
59
+ print(docs.count, "fetched,", len(docs.failed_results), "missing")
60
+
61
+ asyncio.run(main())
62
+ ```
63
+
64
+ ## `search(...)`
65
+
66
+ ```python
67
+ client.search(
68
+ query,
69
+ count=10, # 1..50
70
+ freshness="all", # pd | pw | pm | p3m | p1y | all
71
+ include_domains=None, # ["techcrunch.com", ...]
72
+ exclude_domains=None,
73
+ start_date=None, # "YYYY-MM-DD" or epoch seconds
74
+ end_date=None,
75
+ language=None, # "en", "pt", ...
76
+ offset=0, # pagination, up to 200
77
+ include_excerpt=False, # opt in to longer excerpts
78
+ excerpt_chars=None, # up to 1200
79
+ )
80
+ ```
81
+
82
+ Returns a `SearchResponse` (iterable over `SearchResult`):
83
+
84
+ ```python
85
+ res.query # echoed query
86
+ res.results # list[SearchResult]
87
+ res.count # number of results returned
88
+ res.offset
89
+ res.more_results # bool — more pages available
90
+ res.remaining_quota # int — your remaining monthly units
91
+
92
+ # SearchResult fields:
93
+ # title, url, snippet, domain, site_name, favicon,
94
+ # published_at, age_seconds, language, score
95
+ ```
96
+
97
+ Search always costs **1 credit**, regardless of parameters or excerpt size.
98
+
99
+ ## `fetch(url_or_urls)`
100
+
101
+ ```python
102
+ # single URL -> FetchResult
103
+ doc = client.fetch("https://example.com/a")
104
+ doc.url, doc.canonical_url, doc.title, doc.content, doc.domain, doc.published_at, doc.language, doc.found
105
+
106
+ # list of URLs -> BatchFetchResponse
107
+ batch = client.fetch(["https://a.com", "https://b.com"])
108
+ batch.results # list[FetchResult] that were found
109
+ batch.failed_results # list[FetchFailure] (url, found=False)
110
+ batch.count # number found (== credits billed)
111
+ batch.remaining_quota
112
+ ```
113
+
114
+ Batches over the server cap of **50 URLs** are automatically split into ≤50-URL
115
+ calls, run sequentially with a small delay, and merged for you. Fetch bills per
116
+ document found.
117
+
118
+ ## Errors
119
+
120
+ All errors subclass `blopus.BlopusError`:
121
+
122
+ | Exception | When |
123
+ |----------------------|----------------------------------------|
124
+ | `AuthError` | 401 / 403 — bad, missing or revoked key |
125
+ | `QuotaError` | 402 — monthly quota exhausted |
126
+ | `RateLimitError` | 429 — slow down (`.retry_after`) |
127
+ | `NotFoundError` | 404 — no indexed content for a URL |
128
+ | `BadRequestError` | 400 / 413 — malformed / too large |
129
+ | `ServerError` | 5xx — gateway/backend problem |
130
+ | `APIConnectionError` | network failure (no response) |
131
+
132
+ Requests to `429`/`5xx`/connection errors are retried with exponential backoff
133
+ (honoring `Retry-After`); tune with `Blopus(max_retries=...)`.
134
+
135
+ ## MCP
136
+
137
+ Blopus also exposes a hosted MCP server (`search` + `fetch` tools) at
138
+ `https://mcp.blopus.ai`, using the same Bearer auth.
139
+
140
+ ```python
141
+ from blopus import mcp_config, print_mcp_config
142
+ print_mcp_config() # prints the mcpServers JSON to paste into your MCP client
143
+ cfg = mcp_config() # or get it as a dict
144
+ ```
145
+
146
+ ## CLI
147
+
148
+ ```bash
149
+ blopus search "who won the game" --count 5 --freshness pd
150
+ blopus search "openai" --include-domains techcrunch.com,theverge.com --json
151
+ blopus fetch https://example.com/a https://example.com/b
152
+ blopus mcp-config
153
+ ```
154
+
155
+ ## License
156
+
157
+ MIT
@@ -0,0 +1,61 @@
1
+ """Blopus — official Python SDK for the Blopus web search + fetch API.
2
+
3
+ Quickstart::
4
+
5
+ from blopus import Blopus
6
+
7
+ client = Blopus(api_key="blp_live_...") # or set BLOPUS_API_KEY
8
+ for hit in client.search("latest ai news", freshness="pd"):
9
+ print(hit.title, hit.url)
10
+
11
+ doc = client.fetch("https://example.com/article")
12
+
13
+ The SDK calls only two data-plane endpoints — ``POST /v1/search`` and
14
+ ``POST /v1/fetch`` on https://api.blopus.ai.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from ._async import AsyncBlopus
19
+ from ._version import __version__
20
+ from .client import Blopus
21
+ from .exceptions import (
22
+ APIConnectionError,
23
+ AuthError,
24
+ BadRequestError,
25
+ BlopusError,
26
+ NotFoundError,
27
+ QuotaError,
28
+ RateLimitError,
29
+ ServerError,
30
+ )
31
+ from .mcp import mcp_config, print_mcp_config
32
+ from .models import (
33
+ BatchFetchResponse,
34
+ FetchFailure,
35
+ FetchResult,
36
+ SearchResponse,
37
+ SearchResult,
38
+ )
39
+
40
+ __all__ = [
41
+ "__version__",
42
+ "Blopus",
43
+ "AsyncBlopus",
44
+ "mcp_config",
45
+ "print_mcp_config",
46
+ # models
47
+ "SearchResponse",
48
+ "SearchResult",
49
+ "FetchResult",
50
+ "FetchFailure",
51
+ "BatchFetchResponse",
52
+ # exceptions
53
+ "BlopusError",
54
+ "APIConnectionError",
55
+ "AuthError",
56
+ "BadRequestError",
57
+ "NotFoundError",
58
+ "RateLimitError",
59
+ "QuotaError",
60
+ "ServerError",
61
+ ]
@@ -0,0 +1,139 @@
1
+ """Asynchronous Blopus client (httpx.AsyncClient). Mirrors :class:`Blopus` exactly.
2
+
3
+ import asyncio
4
+ from blopus import AsyncBlopus
5
+
6
+ async def main():
7
+ async with AsyncBlopus() as client:
8
+ res = await client.search("openai news", freshness="pw")
9
+ docs = await client.fetch([r.url for r in res])
10
+
11
+ asyncio.run(main())
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from typing import Optional, Sequence, Union
17
+
18
+ import httpx
19
+
20
+ from . import _common as C
21
+ from .exceptions import APIConnectionError
22
+ from .models import BatchFetchResponse, FetchFailure, FetchResult, SearchResponse
23
+
24
+
25
+ class AsyncBlopus:
26
+ """Async client for the Blopus search + fetch API. Same surface as :class:`Blopus`."""
27
+
28
+ def __init__(
29
+ self,
30
+ api_key: Optional[str] = None,
31
+ *,
32
+ base_url: Optional[str] = None,
33
+ timeout: float = C.DEFAULT_TIMEOUT,
34
+ max_retries: int = C.DEFAULT_MAX_RETRIES,
35
+ chunk_delay: float = C.DEFAULT_CHUNK_DELAY,
36
+ user_agent: str = C.USER_AGENT,
37
+ ) -> None:
38
+ self._api_key = C.resolve_api_key(api_key)
39
+ self.base_url = C.resolve_base_url(base_url)
40
+ self.max_retries = max_retries
41
+ self.chunk_delay = chunk_delay
42
+ self._http = httpx.AsyncClient(
43
+ base_url=self.base_url,
44
+ timeout=timeout,
45
+ headers=C.build_headers(self._api_key, user_agent),
46
+ )
47
+
48
+ async def aclose(self) -> None:
49
+ await self._http.aclose()
50
+
51
+ async def __aenter__(self) -> "AsyncBlopus":
52
+ return self
53
+
54
+ async def __aexit__(self, *exc) -> None:
55
+ await self.aclose()
56
+
57
+ async def _post(self, path: str, body: dict) -> dict:
58
+ attempt = 0
59
+ while True:
60
+ status: Optional[int] = None
61
+ retry_after: Optional[int] = None
62
+ try:
63
+ resp = await self._http.post(path, json=body)
64
+ status = resp.status_code
65
+ retry_after = C.parse_retry_after(resp.headers.get("Retry-After"))
66
+ if 200 <= status < 300:
67
+ return C.parse_json_or_raise(status, resp.text, retry_after)
68
+ except httpx.HTTPError as exc:
69
+ if not C.should_retry(None, attempt, self.max_retries):
70
+ raise APIConnectionError(f"Request failed: {exc}") from exc
71
+ await asyncio.sleep(C.backoff_seconds(attempt, None))
72
+ attempt += 1
73
+ continue
74
+
75
+ if C.should_retry(status, attempt, self.max_retries):
76
+ await asyncio.sleep(C.backoff_seconds(attempt, retry_after))
77
+ attempt += 1
78
+ continue
79
+ return C.parse_json_or_raise(status, resp.text, retry_after)
80
+
81
+ async def search(
82
+ self,
83
+ query: str,
84
+ *,
85
+ count: int = 10,
86
+ freshness: str = "all",
87
+ include_domains: Optional[Sequence[str]] = None,
88
+ exclude_domains: Optional[Sequence[str]] = None,
89
+ start_date: Optional[str] = None,
90
+ end_date: Optional[str] = None,
91
+ language: Optional[str] = None,
92
+ offset: int = 0,
93
+ include_excerpt: bool = False,
94
+ excerpt_chars: Optional[int] = None,
95
+ ) -> SearchResponse:
96
+ body = C.build_search_body(
97
+ query,
98
+ count=count,
99
+ freshness=freshness,
100
+ include_domains=include_domains,
101
+ exclude_domains=exclude_domains,
102
+ start_date=start_date,
103
+ end_date=end_date,
104
+ language=language,
105
+ offset=offset,
106
+ include_excerpt=include_excerpt,
107
+ excerpt_chars=excerpt_chars,
108
+ )
109
+ return SearchResponse.from_dict(await self._post("/v1/search", body))
110
+
111
+ async def fetch(
112
+ self, url_or_urls: Union[str, Sequence[str]]
113
+ ) -> Union[FetchResult, BatchFetchResponse]:
114
+ is_batch, urls, single = C.normalize_fetch_urls(url_or_urls)
115
+ if not is_batch:
116
+ data = await self._post("/v1/fetch", {"url": single})
117
+ return FetchResult.from_dict(data)
118
+ return await self._fetch_batch(urls)
119
+
120
+ async def _fetch_batch(self, urls: Sequence[str]) -> BatchFetchResponse:
121
+ chunks = C.chunk_urls(urls)
122
+ merged_results: list[FetchResult] = []
123
+ merged_failed: list[FetchFailure] = []
124
+ remaining: Optional[int] = None
125
+ for i, chunk in enumerate(chunks):
126
+ if i > 0 and self.chunk_delay > 0:
127
+ await asyncio.sleep(self.chunk_delay)
128
+ data = await self._post("/v1/fetch", {"urls": list(chunk)})
129
+ part = BatchFetchResponse.from_dict(data)
130
+ merged_results.extend(part.results)
131
+ merged_failed.extend(part.failed_results)
132
+ if part.remaining_quota is not None:
133
+ remaining = part.remaining_quota
134
+ return BatchFetchResponse(
135
+ results=merged_results,
136
+ failed_results=merged_failed,
137
+ count=len(merged_results),
138
+ remaining_quota=remaining,
139
+ )