anysearch-py 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WH-2099
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,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: anysearch-py
3
+ Version: 0.1.0
4
+ Summary: Typed Python client for AnySearch.
5
+ Author: WH-2099
6
+ Author-email: WH-2099 <wh2099@pm.me>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: pydantic>=2.12
10
+ Requires-Dist: urllib3-future>=2.12.910
11
+ Requires-Python: >=3.14
12
+ Project-URL: source, https://github.com/WH-2099/anysearch-py
13
+ Project-URL: homepage, https://github.com/WH-2099/anysearch-py
14
+ Project-URL: issues, https://github.com/WH-2099/anysearch-py/issues
15
+ Description-Content-Type: text/markdown
16
+
17
+ # anysearch-py
18
+
19
+ Typed Python clients for AnySearch.
20
+
21
+ The package has two first-class clients.
22
+
23
+ `Client` calls the REST Web API.
24
+
25
+ `MCPClient` calls the MCP `tools/call` API used by `anysearch-skill/`.
26
+
27
+ Both clients share the same Pydantic models, enums, validation, and serialization logic.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ uv add anysearch-py
33
+ ```
34
+
35
+ ## REST Search
36
+
37
+ ```python
38
+ from anysearch_py import Client, SearchTag
39
+
40
+ with Client(api_key="YOUR_ANYSEARCH_API_KEY") as client:
41
+ response = client.search(
42
+ "Go 1.26 release notes",
43
+ tag=SearchTag.CODE_DOC,
44
+ params={"library": "golang"},
45
+ )
46
+
47
+ print(response.data.results[0].url)
48
+ ```
49
+
50
+ ## Async REST Search
51
+
52
+ ```python
53
+ from anysearch_py import AsyncClient
54
+
55
+ async with AsyncClient(api_key="YOUR_ANYSEARCH_API_KEY") as client:
56
+ response = await client.search("what is quantum computing")
57
+ ```
58
+
59
+ ## MCP Tools
60
+
61
+ ```python
62
+ from anysearch_py import Domain, MCPClient
63
+
64
+ with MCPClient(api_key="YOUR_ANYSEARCH_API_KEY") as client:
65
+ markdown = client.get_sub_domains(domains=[Domain.FINANCE, Domain.HEALTH])
66
+
67
+ print(markdown)
68
+ ```
69
+
70
+ ## MCP Search
71
+
72
+ ```python
73
+ from anysearch_py import Domain, MCPClient
74
+
75
+ with MCPClient(api_key="YOUR_ANYSEARCH_API_KEY") as client:
76
+ markdown = client.search(
77
+ "AAPL",
78
+ domain=Domain.FINANCE,
79
+ sub_domain="finance.quote",
80
+ sub_domain_params="type=stock,symbol=AAPL,cn_code=",
81
+ max_results=5,
82
+ )
83
+
84
+ print(markdown)
85
+ ```
86
+
87
+ ## Live Tests
88
+
89
+ Live tests read `ANYSEARCH_API_KEY` from `.env` or `anysearch-skill/.env`.
90
+
91
+ If neither file exists, those tests skip automatically.
92
+
93
+ Package code never reads `.env`.
@@ -0,0 +1,77 @@
1
+ # anysearch-py
2
+
3
+ Typed Python clients for AnySearch.
4
+
5
+ The package has two first-class clients.
6
+
7
+ `Client` calls the REST Web API.
8
+
9
+ `MCPClient` calls the MCP `tools/call` API used by `anysearch-skill/`.
10
+
11
+ Both clients share the same Pydantic models, enums, validation, and serialization logic.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ uv add anysearch-py
17
+ ```
18
+
19
+ ## REST Search
20
+
21
+ ```python
22
+ from anysearch_py import Client, SearchTag
23
+
24
+ with Client(api_key="YOUR_ANYSEARCH_API_KEY") as client:
25
+ response = client.search(
26
+ "Go 1.26 release notes",
27
+ tag=SearchTag.CODE_DOC,
28
+ params={"library": "golang"},
29
+ )
30
+
31
+ print(response.data.results[0].url)
32
+ ```
33
+
34
+ ## Async REST Search
35
+
36
+ ```python
37
+ from anysearch_py import AsyncClient
38
+
39
+ async with AsyncClient(api_key="YOUR_ANYSEARCH_API_KEY") as client:
40
+ response = await client.search("what is quantum computing")
41
+ ```
42
+
43
+ ## MCP Tools
44
+
45
+ ```python
46
+ from anysearch_py import Domain, MCPClient
47
+
48
+ with MCPClient(api_key="YOUR_ANYSEARCH_API_KEY") as client:
49
+ markdown = client.get_sub_domains(domains=[Domain.FINANCE, Domain.HEALTH])
50
+
51
+ print(markdown)
52
+ ```
53
+
54
+ ## MCP Search
55
+
56
+ ```python
57
+ from anysearch_py import Domain, MCPClient
58
+
59
+ with MCPClient(api_key="YOUR_ANYSEARCH_API_KEY") as client:
60
+ markdown = client.search(
61
+ "AAPL",
62
+ domain=Domain.FINANCE,
63
+ sub_domain="finance.quote",
64
+ sub_domain_params="type=stock,symbol=AAPL,cn_code=",
65
+ max_results=5,
66
+ )
67
+
68
+ print(markdown)
69
+ ```
70
+
71
+ ## Live Tests
72
+
73
+ Live tests read `ANYSEARCH_API_KEY` from `.env` or `anysearch-skill/.env`.
74
+
75
+ If neither file exists, those tests skip automatically.
76
+
77
+ Package code never reads `.env`.
@@ -0,0 +1,82 @@
1
+ [project]
2
+ name = 'anysearch-py'
3
+ version = '0.1.0'
4
+ description = 'Typed Python client for AnySearch.'
5
+ readme = 'README.md'
6
+ license = 'MIT'
7
+ license-files = ['LICENSE']
8
+ authors = [
9
+ { name = 'WH-2099', email = 'wh2099@pm.me' }
10
+ ]
11
+ requires-python = '>=3.14'
12
+ dependencies = [
13
+ 'pydantic>=2.12',
14
+ 'urllib3-future>=2.12.910',
15
+ ]
16
+
17
+
18
+ [project.urls]
19
+ source = 'https://github.com/WH-2099/anysearch-py'
20
+ homepage = 'https://github.com/WH-2099/anysearch-py'
21
+ issues = 'https://github.com/WH-2099/anysearch-py/issues'
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ 'pytest',
26
+ 'pytest-cov',
27
+ 'pytest-mock',
28
+ 'ruff',
29
+ 'prek',
30
+ 'ty',
31
+ ]
32
+
33
+ [build-system]
34
+ requires = ['uv_build~=0.11']
35
+ build-backend = 'uv_build'
36
+
37
+ [tool.uv]
38
+ default-groups = ['dev']
39
+
40
+ [tool.ruff]
41
+ fix = true
42
+ preview = true
43
+ required-version = '>=0.15'
44
+ extend-exclude = ['anysearch-skill']
45
+
46
+ [tool.ruff.lint]
47
+ select = ['ALL']
48
+ ignore = [
49
+ 'COM812', # Conflicts with `ruff format`.
50
+ 'ERA001', # False positives are common.
51
+ 'TC', # Runtime use of typing constructs is common now.
52
+ 'CPY', # Covered by the standalone LICENSE file.
53
+ 'PLR0913', # Explicit is better than implicit.
54
+ 'PLR0917', # Complex is better than complicated.
55
+ 'PLR6301', # Violates the usual practice.
56
+ 'D', # Too verbose.
57
+ 'FBT', # Too strict.
58
+ 'TID252', # Too absolute.
59
+ 'ANN401', # We hope to enforce this, but typing is still evolving.
60
+ ]
61
+
62
+ [tool.ruff.lint.pydoclint]
63
+ ignore-one-line-docstrings = true
64
+
65
+ [tool.ruff.lint.per-file-ignores]
66
+ 'tests/**/*.py' = [
67
+ 'S101', # Assert statements used for pytest.
68
+ 'PLR2004', # Magic value used in test cases.
69
+ 'PLC2701', # Allow import private members from graphon module.
70
+ 'PLC1901', # be more precise about assertion in tests.
71
+
72
+ # permit calls of private methods until we separate
73
+ # public interface tests from implementation tests.
74
+ 'SLF001', # allow calls of private class methods in tests
75
+ ]
76
+
77
+ [tool.ty.environment]
78
+ python-platform = 'linux'
79
+
80
+ [tool.pytest]
81
+ minversion = '9.0'
82
+ testpaths = ['tests']
@@ -0,0 +1,72 @@
1
+ """Typed Python clients for AnySearch."""
2
+
3
+ from .client import AsyncClient, AsyncMCPClient, Client, MCPClient
4
+ from .exceptions import (
5
+ AnySearchError,
6
+ APIError,
7
+ HTTPStatusError,
8
+ JSONRPCError,
9
+ ResponseDecodeError,
10
+ ResponseValidationError,
11
+ TransportError,
12
+ )
13
+ from .models import (
14
+ APIEnvelope,
15
+ BatchSearchArguments,
16
+ Domain,
17
+ ExtractArguments,
18
+ GetSubDomainsArguments,
19
+ MCPArguments,
20
+ MCPContentBlock,
21
+ MCPToolCall,
22
+ MCPToolResponse,
23
+ MCPToolResult,
24
+ OutputFormat,
25
+ SearchArgumentInput,
26
+ SearchArguments,
27
+ SearchData,
28
+ SearchMetadata,
29
+ SearchRequest,
30
+ SearchResponse,
31
+ SearchResult,
32
+ SearchTag,
33
+ SubDomainParams,
34
+ ToolName,
35
+ Zone,
36
+ )
37
+
38
+ __all__ = [
39
+ "APIEnvelope",
40
+ "APIError",
41
+ "AnySearchError",
42
+ "AsyncClient",
43
+ "AsyncMCPClient",
44
+ "BatchSearchArguments",
45
+ "Client",
46
+ "Domain",
47
+ "ExtractArguments",
48
+ "GetSubDomainsArguments",
49
+ "HTTPStatusError",
50
+ "JSONRPCError",
51
+ "MCPArguments",
52
+ "MCPClient",
53
+ "MCPContentBlock",
54
+ "MCPToolCall",
55
+ "MCPToolResponse",
56
+ "MCPToolResult",
57
+ "OutputFormat",
58
+ "ResponseDecodeError",
59
+ "ResponseValidationError",
60
+ "SearchArgumentInput",
61
+ "SearchArguments",
62
+ "SearchData",
63
+ "SearchMetadata",
64
+ "SearchRequest",
65
+ "SearchResponse",
66
+ "SearchResult",
67
+ "SearchTag",
68
+ "SubDomainParams",
69
+ "ToolName",
70
+ "TransportError",
71
+ "Zone",
72
+ ]
@@ -0,0 +1,383 @@
1
+ """HTTP clients for AnySearch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ from itertools import count
8
+ from typing import Any, Self
9
+
10
+ import urllib3
11
+ from urllib3.util import Retry, Timeout
12
+
13
+ from .exceptions import (
14
+ HTTPStatusError,
15
+ ResponseDecodeError,
16
+ TransportError,
17
+ )
18
+ from .models import (
19
+ APIErrorDetails,
20
+ BatchSearchArguments,
21
+ Domain,
22
+ ExtractArguments,
23
+ GetSubDomainsArguments,
24
+ JsonObject,
25
+ JsonValue,
26
+ MCPArguments,
27
+ MCPToolCall,
28
+ MCPToolResponse,
29
+ MCPToolResult,
30
+ OutputFormat,
31
+ SearchArgumentInput,
32
+ SearchArguments,
33
+ SearchRequest,
34
+ SearchResponse,
35
+ SearchTag,
36
+ ToolName,
37
+ Zone,
38
+ )
39
+
40
+ API_BASE_URL = "https://api.anysearch.com"
41
+ REST_SEARCH_PATH = "/v1/search"
42
+ MCP_PATH = "/mcp"
43
+ USER_AGENT = "anysearch-py/0.1.0"
44
+
45
+ DEFAULT_TIMEOUT = Timeout(connect=10.0, read=30.0)
46
+ DEFAULT_RETRIES = Retry(
47
+ total=2,
48
+ connect=2,
49
+ read=2,
50
+ status=2,
51
+ backoff_factor=0.25,
52
+ allowed_methods=frozenset({"GET", "POST"}),
53
+ status_forcelist=frozenset({500, 502, 503, 504}),
54
+ raise_on_status=False,
55
+ )
56
+ HTTP_OK_MIN = 200
57
+ HTTP_REDIRECT_MIN = 300
58
+
59
+
60
+ @dataclass(kw_only=True)
61
+ class _HTTPClient:
62
+ api_key: str | None = None
63
+ base_url: str = API_BASE_URL
64
+ user_agent: str = USER_AGENT
65
+
66
+ def _headers(self) -> dict[str, str]:
67
+ headers = {
68
+ "Accept": "application/json",
69
+ "Content-Type": "application/json",
70
+ "User-Agent": self.user_agent,
71
+ }
72
+ if self.api_key:
73
+ headers["Authorization"] = f"Bearer {self.api_key}"
74
+ return headers
75
+
76
+ def _url(self, path: str) -> str:
77
+ return f"{self.base_url.rstrip('/')}/{path.lstrip('/')}"
78
+
79
+ def _raise_for_http_status(self, response: Any, payload: Any) -> None:
80
+ status = int(getattr(response, "status", 0))
81
+ if HTTP_OK_MIN <= status < HTTP_REDIRECT_MIN:
82
+ return
83
+
84
+ details = APIErrorDetails.from_payload(
85
+ payload,
86
+ fallback_message=f"HTTP {status} response from AnySearch",
87
+ )
88
+ headers = getattr(response, "headers", None)
89
+ raise HTTPStatusError(
90
+ details.message,
91
+ status=status,
92
+ code=details.code,
93
+ request_id=details.request_id,
94
+ data=details.data,
95
+ headers={str(key): str(value) for key, value in headers.items()}
96
+ if hasattr(headers, "items")
97
+ else {},
98
+ )
99
+
100
+
101
+ class _SyncHTTPClient(_HTTPClient):
102
+ def __init__(
103
+ self,
104
+ *,
105
+ api_key: str | None = None,
106
+ base_url: str = API_BASE_URL,
107
+ timeout: Timeout = DEFAULT_TIMEOUT,
108
+ retries: Retry | bool | int = DEFAULT_RETRIES,
109
+ user_agent: str = USER_AGENT,
110
+ http: Any | None = None,
111
+ ) -> None:
112
+ super().__init__(api_key=api_key, base_url=base_url, user_agent=user_agent)
113
+ self.http = http or urllib3.PoolManager(timeout=timeout, retries=retries)
114
+ self.owns_http = http is None
115
+
116
+ def __enter__(self) -> Self:
117
+ return self
118
+
119
+ def __exit__(self, *exc_info: object) -> None:
120
+ del exc_info
121
+ self.close()
122
+
123
+ def close(self) -> None:
124
+ if self.owns_http and hasattr(self.http, "clear"):
125
+ self.http.clear()
126
+
127
+ def post_json(self, path: str, payload: JsonObject) -> Any:
128
+ try:
129
+ response = self.http.request(
130
+ "POST",
131
+ self._url(path),
132
+ headers=self._headers(),
133
+ json=payload,
134
+ )
135
+ except urllib3.exceptions.HTTPError as exc:
136
+ msg = "AnySearch HTTP request failed"
137
+ raise TransportError(msg, cause=exc) from exc
138
+
139
+ try:
140
+ decoded = response.json()
141
+ except ValueError as exc:
142
+ raw = getattr(response, "data", None)
143
+ msg = "response body is not valid JSON"
144
+ raise ResponseDecodeError(msg, raw=raw) from exc
145
+ self._raise_for_http_status(response, decoded)
146
+ return decoded
147
+
148
+
149
+ class _AsyncHTTPClient(_HTTPClient):
150
+ def __init__(
151
+ self,
152
+ *,
153
+ api_key: str | None = None,
154
+ base_url: str = API_BASE_URL,
155
+ timeout: Timeout = DEFAULT_TIMEOUT,
156
+ retries: Retry | bool | int = DEFAULT_RETRIES,
157
+ user_agent: str = USER_AGENT,
158
+ http: Any | None = None,
159
+ ) -> None:
160
+ super().__init__(api_key=api_key, base_url=base_url, user_agent=user_agent)
161
+ self.http = http or urllib3.AsyncPoolManager(timeout=timeout, retries=retries)
162
+ self.owns_http = http is None
163
+
164
+ async def __aenter__(self) -> Self:
165
+ return self
166
+
167
+ async def __aexit__(self, *exc_info: object) -> None:
168
+ del exc_info
169
+ await self.aclose()
170
+
171
+ async def aclose(self) -> None:
172
+ if not self.owns_http or not hasattr(self.http, "clear"):
173
+ return
174
+ result = self.http.clear()
175
+ if hasattr(result, "__await__"):
176
+ await result
177
+
178
+ async def post_json(self, path: str, payload: JsonObject) -> Any:
179
+ try:
180
+ response = await self.http.request(
181
+ "POST",
182
+ self._url(path),
183
+ headers=self._headers(),
184
+ json=payload,
185
+ )
186
+ except urllib3.exceptions.HTTPError as exc:
187
+ msg = "AnySearch HTTP request failed"
188
+ raise TransportError(msg, cause=exc) from exc
189
+
190
+ try:
191
+ decoded = await response.json()
192
+ except ValueError as exc:
193
+ msg = "response body is not valid JSON"
194
+ raise ResponseDecodeError(msg) from exc
195
+ self._raise_for_http_status(response, decoded)
196
+ return decoded
197
+
198
+
199
+ class Client(_SyncHTTPClient):
200
+ """Synchronous AnySearch REST API client."""
201
+
202
+ def search(
203
+ self,
204
+ request: SearchRequest | str,
205
+ *,
206
+ max_results: int = 10,
207
+ tag: SearchTag | str | None = None,
208
+ zone: Zone | str | None = None,
209
+ language: str | None = None,
210
+ params: Mapping[str, JsonValue] | None = None,
211
+ output_format: OutputFormat | str = OutputFormat.JSON,
212
+ ) -> SearchResponse:
213
+ search_request = SearchRequest.from_input(
214
+ request,
215
+ max_results=max_results,
216
+ tag=tag,
217
+ zone=zone,
218
+ language=language,
219
+ params=params,
220
+ output_format=output_format,
221
+ )
222
+ payload = self.post_json(REST_SEARCH_PATH, search_request.to_payload())
223
+ return SearchResponse.from_payload(payload)
224
+
225
+
226
+ class MCPClient(_SyncHTTPClient):
227
+ """Synchronous AnySearch MCP tools/call client."""
228
+
229
+ def __init__(self, **kwargs: Any) -> None:
230
+ super().__init__(**kwargs)
231
+ self.ids = count(1)
232
+
233
+ def call_tool(
234
+ self,
235
+ tool: ToolName | str | MCPArguments | MCPToolCall,
236
+ arguments: Mapping[str, JsonValue] | None = None,
237
+ ) -> MCPToolResult:
238
+ tool_call = (
239
+ tool
240
+ if isinstance(tool, MCPToolCall)
241
+ else tool.to_tool_call()
242
+ if isinstance(tool, MCPArguments)
243
+ else MCPToolCall.from_arguments(tool, arguments)
244
+ )
245
+ payload = self.post_json(
246
+ MCP_PATH, tool_call.to_request(next(self.ids)).to_payload()
247
+ )
248
+ return MCPToolResponse.from_payload(payload)
249
+
250
+ def search(
251
+ self, arguments: SearchArguments | str | Mapping[str, Any], **kwargs: Any
252
+ ) -> str:
253
+ args = SearchArguments.from_input(arguments, **kwargs)
254
+ return self.call_tool(args).text
255
+
256
+ def batch_search(
257
+ self,
258
+ queries: Sequence[SearchArgumentInput] | BatchSearchArguments,
259
+ *,
260
+ domain: Domain | str | None = None,
261
+ sub_domain: str | None = None,
262
+ sub_domain_params: Mapping[str, JsonValue] | str | None = None,
263
+ ) -> str:
264
+ args = (
265
+ queries
266
+ if isinstance(queries, BatchSearchArguments)
267
+ else BatchSearchArguments.from_queries(
268
+ queries,
269
+ domain=domain,
270
+ sub_domain=sub_domain,
271
+ sub_domain_params=sub_domain_params,
272
+ )
273
+ )
274
+ return self.call_tool(args).text
275
+
276
+ def get_sub_domains(
277
+ self,
278
+ *,
279
+ domain: Domain | str | None = None,
280
+ domains: Sequence[Domain | str] | None = None,
281
+ ) -> str:
282
+ args = GetSubDomainsArguments.from_selector(domain=domain, domains=domains)
283
+ return self.call_tool(args).text
284
+
285
+ def extract(self, url: str) -> str:
286
+ return self.call_tool(ExtractArguments.from_url(url)).text
287
+
288
+
289
+ class AsyncClient(_AsyncHTTPClient):
290
+ """Asynchronous AnySearch REST API client."""
291
+
292
+ async def search(
293
+ self,
294
+ request: SearchRequest | str,
295
+ *,
296
+ max_results: int = 10,
297
+ tag: SearchTag | str | None = None,
298
+ zone: Zone | str | None = None,
299
+ language: str | None = None,
300
+ params: Mapping[str, JsonValue] | None = None,
301
+ output_format: OutputFormat | str = OutputFormat.JSON,
302
+ ) -> SearchResponse:
303
+ search_request = SearchRequest.from_input(
304
+ request,
305
+ max_results=max_results,
306
+ tag=tag,
307
+ zone=zone,
308
+ language=language,
309
+ params=params,
310
+ output_format=output_format,
311
+ )
312
+ payload = await self.post_json(REST_SEARCH_PATH, search_request.to_payload())
313
+ return SearchResponse.from_payload(payload)
314
+
315
+
316
+ class AsyncMCPClient(_AsyncHTTPClient):
317
+ """Asynchronous AnySearch MCP tools/call client."""
318
+
319
+ def __init__(self, **kwargs: Any) -> None:
320
+ super().__init__(**kwargs)
321
+ self.ids = count(1)
322
+
323
+ async def call_tool(
324
+ self,
325
+ tool: ToolName | str | MCPArguments | MCPToolCall,
326
+ arguments: Mapping[str, JsonValue] | None = None,
327
+ ) -> MCPToolResult:
328
+ tool_call = (
329
+ tool
330
+ if isinstance(tool, MCPToolCall)
331
+ else tool.to_tool_call()
332
+ if isinstance(tool, MCPArguments)
333
+ else MCPToolCall.from_arguments(tool, arguments)
334
+ )
335
+ payload = await self.post_json(
336
+ MCP_PATH,
337
+ tool_call.to_request(next(self.ids)).to_payload(),
338
+ )
339
+ return MCPToolResponse.from_payload(payload)
340
+
341
+ async def search(
342
+ self,
343
+ arguments: SearchArguments | str | Mapping[str, Any],
344
+ **kwargs: Any,
345
+ ) -> str:
346
+ args = SearchArguments.from_input(arguments, **kwargs)
347
+ result = await self.call_tool(args)
348
+ return result.text
349
+
350
+ async def batch_search(
351
+ self,
352
+ queries: Sequence[SearchArgumentInput] | BatchSearchArguments,
353
+ *,
354
+ domain: Domain | str | None = None,
355
+ sub_domain: str | None = None,
356
+ sub_domain_params: Mapping[str, JsonValue] | str | None = None,
357
+ ) -> str:
358
+ args = (
359
+ queries
360
+ if isinstance(queries, BatchSearchArguments)
361
+ else BatchSearchArguments.from_queries(
362
+ queries,
363
+ domain=domain,
364
+ sub_domain=sub_domain,
365
+ sub_domain_params=sub_domain_params,
366
+ )
367
+ )
368
+ result = await self.call_tool(args)
369
+ return result.text
370
+
371
+ async def get_sub_domains(
372
+ self,
373
+ *,
374
+ domain: Domain | str | None = None,
375
+ domains: Sequence[Domain | str] | None = None,
376
+ ) -> str:
377
+ args = GetSubDomainsArguments.from_selector(domain=domain, domains=domains)
378
+ result = await self.call_tool(args)
379
+ return result.text
380
+
381
+ async def extract(self, url: str) -> str:
382
+ result = await self.call_tool(ExtractArguments.from_url(url))
383
+ return result.text
@@ -0,0 +1,62 @@
1
+ """Exception hierarchy for AnySearch clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class AnySearchError(Exception):
9
+ """Base class for all package-specific errors."""
10
+
11
+
12
+ class TransportError(AnySearchError):
13
+ """Raised when the HTTP client cannot complete a request."""
14
+
15
+ def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
16
+ super().__init__(message)
17
+ self.__cause__ = cause
18
+
19
+
20
+ class ResponseDecodeError(AnySearchError):
21
+ """Raised when a response cannot be decoded as JSON."""
22
+
23
+ def __init__(self, message: str, *, raw: Any = None) -> None:
24
+ super().__init__(message)
25
+ self.raw = raw
26
+
27
+
28
+ class ResponseValidationError(AnySearchError):
29
+ """Raised when a decoded response does not match the expected schema."""
30
+
31
+ def __init__(self, message: str, *, payload: Any = None) -> None:
32
+ super().__init__(message)
33
+ self.payload = payload
34
+
35
+
36
+ class APIError(AnySearchError):
37
+ """Raised when AnySearch returns an application-level error."""
38
+
39
+ def __init__(
40
+ self,
41
+ message: str,
42
+ *,
43
+ status: int | None = None,
44
+ code: int | str | None = None,
45
+ request_id: str | None = None,
46
+ data: Any = None,
47
+ headers: dict[str, str] | None = None,
48
+ ) -> None:
49
+ super().__init__(message)
50
+ self.status = status
51
+ self.code = code
52
+ self.request_id = request_id
53
+ self.data = data
54
+ self.headers = headers or {}
55
+
56
+
57
+ class HTTPStatusError(APIError):
58
+ """Raised for non-2xx HTTP responses."""
59
+
60
+
61
+ class JSONRPCError(APIError):
62
+ """Raised for JSON-RPC error objects or MCP tool failures."""
@@ -0,0 +1,580 @@
1
+ """Pydantic models shared by the AnySearch REST and MCP clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping, Sequence
7
+ from enum import StrEnum
8
+ from typing import Annotated, Any, ClassVar, Literal, Self
9
+
10
+ from pydantic import (
11
+ BaseModel,
12
+ ConfigDict,
13
+ Field,
14
+ HttpUrl,
15
+ RootModel,
16
+ StringConstraints,
17
+ TypeAdapter,
18
+ ValidationError,
19
+ computed_field,
20
+ model_validator,
21
+ )
22
+
23
+ from .exceptions import APIError, JSONRPCError, ResponseValidationError
24
+
25
+ type JsonScalar = str | int | float | bool | None
26
+ type JsonValue = JsonScalar | list[JsonValue] | dict[str, JsonValue]
27
+ type JsonObject = dict[str, JsonValue]
28
+ type SearchArgumentInput = SearchArguments | str | Mapping[str, Any]
29
+
30
+ NonEmptyString = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
31
+ LanguageCode = Annotated[
32
+ str,
33
+ StringConstraints(
34
+ strip_whitespace=True,
35
+ min_length=2,
36
+ max_length=16,
37
+ pattern=r"^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})?$",
38
+ ),
39
+ ]
40
+ SubDomainName = Annotated[
41
+ str,
42
+ StringConstraints(
43
+ strip_whitespace=True,
44
+ min_length=3,
45
+ pattern=r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$",
46
+ ),
47
+ ]
48
+
49
+ _JSON_VALUE = TypeAdapter(JsonValue)
50
+
51
+
52
+ class StrictRequestModel(BaseModel):
53
+ """Base class for outbound payloads."""
54
+
55
+ model_config = ConfigDict(
56
+ extra="forbid",
57
+ frozen=True,
58
+ populate_by_name=True,
59
+ str_strip_whitespace=True,
60
+ validate_default=True,
61
+ )
62
+
63
+ def to_payload(self) -> JsonObject:
64
+ dumped = self.model_dump(mode="json", by_alias=True, exclude_none=True)
65
+ if not isinstance(dumped, dict):
66
+ msg = "model dump did not produce a JSON object"
67
+ raise TypeError(msg)
68
+ return dumped
69
+
70
+
71
+ class ResponseModel(BaseModel):
72
+ """Base class for inbound payloads."""
73
+
74
+ model_config = ConfigDict(
75
+ extra="allow",
76
+ frozen=True,
77
+ populate_by_name=True,
78
+ validate_default=True,
79
+ )
80
+
81
+
82
+ class Domain(StrEnum):
83
+ ACADEMIC = "academic"
84
+ AGRICULTURE = "agriculture"
85
+ BUSINESS = "business"
86
+ CODE = "code"
87
+ ENERGY = "energy"
88
+ ENVIRONMENT = "environment"
89
+ FILM = "film"
90
+ FINANCE = "finance"
91
+ GAMING = "gaming"
92
+ GENERAL = "general"
93
+ HEALTH = "health"
94
+ IP = "ip"
95
+ LEGAL = "legal"
96
+ RESOURCE = "resource"
97
+ SECURITY = "security"
98
+ SOCIAL_MEDIA = "social_media"
99
+ TRAVEL = "travel"
100
+
101
+
102
+ class SearchTag(StrEnum):
103
+ ACADEMIC_BIOMEDICAL = "academic.biomedical"
104
+ ACADEMIC_CITATION = "academic.citation"
105
+ ACADEMIC_DATASET = "academic.dataset"
106
+ ACADEMIC_PREPRINT = "academic.preprint"
107
+ ACADEMIC_SEARCH = "academic.search"
108
+ AGRICULTURE_FAO = "agriculture.fao"
109
+ BUSINESS_COMPANY = "business.company"
110
+ BUSINESS_JOBS = "business.jobs"
111
+ BUSINESS_PEOPLE = "business.people"
112
+ BUSINESS_TRADE = "business.trade"
113
+ CODE_DOC = "code.doc"
114
+ CODE_SNIPPET = "code.snippet"
115
+ ENERGY_ELECTRICITY = "energy.electricity"
116
+ ENERGY_PRODUCTION = "energy.production"
117
+ ENVIRONMENT_AQI = "environment.aqi"
118
+ FILM_TORRENT = "film.torrent"
119
+ FINANCE_CALENDAR = "finance.calendar"
120
+ FINANCE_FUNDAMENTAL = "finance.fundamental"
121
+ FINANCE_MACRO = "finance.macro"
122
+ FINANCE_NEWS = "finance.news"
123
+ FINANCE_QUOTE = "finance.quote"
124
+ FINANCE_SCREEN = "finance.screen"
125
+ GAMING_ESPORTS = "gaming.esports"
126
+ GAMING_STORE = "gaming.store"
127
+ GENERAL_GENERAL = "general.general"
128
+ HEALTH_DRUG = "health.drug"
129
+ HEALTH_STATS = "health.stats"
130
+ HEALTH_TRIAL = "health.trial"
131
+ IP_GLOBAL = "ip.global"
132
+ LEGAL_CASE = "legal.case"
133
+ LEGAL_LEGISLATION = "legal.legislation"
134
+ LEGAL_STATUTE = "legal.statute"
135
+ RESOURCE_IMAGE = "resource.image"
136
+ SECURITY_INTEL = "security.intel"
137
+ SECURITY_NOISE = "security.noise"
138
+ SECURITY_SCAN = "security.scan"
139
+ SECURITY_VULN = "security.vuln"
140
+ SOCIAL_MEDIA_SOCIAL_MEDIA = "social_media.social_media"
141
+ TRAVEL_FLIGHT = "travel.flight"
142
+ TRAVEL_FLIGHT_STATUS = "travel.flight_status"
143
+
144
+
145
+ class Zone(StrEnum):
146
+ CHINA = "cn"
147
+ INTERNATIONAL = "intl"
148
+
149
+
150
+ class OutputFormat(StrEnum):
151
+ JSON = "json"
152
+ MARKDOWN = "markdown"
153
+
154
+
155
+ class ToolName(StrEnum):
156
+ SEARCH = "search"
157
+ BATCH_SEARCH = "batch_search"
158
+ GET_SUB_DOMAINS = "get_sub_domains"
159
+ EXTRACT = "extract"
160
+
161
+
162
+ class SearchRequest(StrictRequestModel):
163
+ """REST /v1/search request."""
164
+
165
+ query: NonEmptyString
166
+ max_results: int = Field(default=10, ge=1, le=20)
167
+ tag: SearchTag | None = None
168
+ zone: Zone | None = None
169
+ language: LanguageCode | None = None
170
+ params: JsonObject | None = None
171
+ format: OutputFormat = OutputFormat.JSON
172
+
173
+ @classmethod
174
+ def from_input(
175
+ cls,
176
+ request: Self | str,
177
+ *,
178
+ max_results: int = 10,
179
+ tag: SearchTag | str | None = None,
180
+ zone: Zone | str | None = None,
181
+ language: str | None = None,
182
+ params: Mapping[str, JsonValue] | None = None,
183
+ output_format: OutputFormat | str = OutputFormat.JSON,
184
+ ) -> Self:
185
+ if not isinstance(request, str):
186
+ return request
187
+ return cls.model_validate(
188
+ {
189
+ "query": request,
190
+ "max_results": max_results,
191
+ "tag": tag,
192
+ "zone": zone,
193
+ "language": language,
194
+ "params": dict(params) if params is not None else None,
195
+ "format": output_format,
196
+ },
197
+ )
198
+
199
+
200
+ class SearchResult(ResponseModel):
201
+ """One REST search result."""
202
+
203
+ title: str
204
+ url: HttpUrl
205
+ snippet: str
206
+ content: str | None = None
207
+
208
+
209
+ class SearchMetadata(ResponseModel):
210
+ """REST search metadata."""
211
+
212
+ total_results: int = Field(ge=0)
213
+ search_time_ms: int = Field(ge=0)
214
+
215
+
216
+ class SearchData(ResponseModel):
217
+ """REST search data envelope."""
218
+
219
+ results: tuple[SearchResult, ...]
220
+ metadata: SearchMetadata
221
+
222
+
223
+ class APIErrorDetails(ResponseModel):
224
+ """Normalized application error details."""
225
+
226
+ message: str
227
+ code: int | str | None = None
228
+ request_id: str | None = None
229
+ data: JsonValue | None = None
230
+
231
+ @classmethod
232
+ def from_payload(cls, payload: Any, *, fallback_message: str) -> Self:
233
+ if not isinstance(payload, dict):
234
+ return cls(
235
+ message=fallback_message,
236
+ data=cls.coerce_json(payload),
237
+ )
238
+
239
+ code = payload.get("code")
240
+ if not isinstance(code, int | str):
241
+ code = payload.get("symbol")
242
+ if not isinstance(code, int | str):
243
+ code = None
244
+
245
+ message = payload.get("message")
246
+ if not isinstance(message, str) or not message.strip():
247
+ message = payload.get("error")
248
+ if not isinstance(message, str) or not message.strip():
249
+ message = fallback_message
250
+
251
+ request_id = payload.get("request_id")
252
+ if not isinstance(request_id, str):
253
+ request_id = None
254
+
255
+ return cls(
256
+ message=message,
257
+ code=code,
258
+ request_id=request_id,
259
+ data=cls.coerce_json(payload.get("data")),
260
+ )
261
+
262
+ @staticmethod
263
+ def coerce_json(value: Any) -> JsonValue:
264
+ try:
265
+ return _JSON_VALUE.validate_python(value)
266
+ except ValidationError:
267
+ return str(value)
268
+
269
+
270
+ class APIEnvelope[DataT](ResponseModel):
271
+ """Standard REST response envelope."""
272
+
273
+ code: int
274
+ message: str
275
+ request_id: str | None = None
276
+ data: DataT | None = None
277
+
278
+ @computed_field
279
+ @property
280
+ def ok(self) -> bool:
281
+ return self.code == 0
282
+
283
+ def raise_if_error(self) -> None:
284
+ if self.ok:
285
+ return
286
+
287
+ raise APIError(
288
+ self.message or "AnySearch request failed",
289
+ code=self.code,
290
+ request_id=self.request_id,
291
+ data=APIErrorDetails.coerce_json(self.data),
292
+ )
293
+
294
+
295
+ class SearchResponse(APIEnvelope[SearchData]):
296
+ """Successful REST search response."""
297
+
298
+ data: SearchData
299
+
300
+ @classmethod
301
+ def from_payload(cls, payload: Any) -> Self:
302
+ try:
303
+ APIEnvelope[Any].model_validate(payload).raise_if_error()
304
+ except ValidationError as exc:
305
+ msg = "search response does not match the AnySearch envelope schema"
306
+ raise ResponseValidationError(msg, payload=payload) from exc
307
+
308
+ try:
309
+ return cls.model_validate(payload)
310
+ except ValidationError as exc:
311
+ msg = "successful search response does not match the documented schema"
312
+ raise ResponseValidationError(msg, payload=payload) from exc
313
+
314
+
315
+ class SubDomainParams(RootModel[JsonObject]):
316
+ """MCP sub-domain parameters."""
317
+
318
+ @model_validator(mode="before")
319
+ @classmethod
320
+ def parse_params(cls, value: Any) -> Any:
321
+ if not isinstance(value, str):
322
+ return value
323
+
324
+ stripped = value.strip()
325
+ if not stripped:
326
+ return None
327
+
328
+ try:
329
+ return json.loads(stripped)
330
+ except json.JSONDecodeError:
331
+ parsed = {}
332
+ for item in stripped.split(","):
333
+ key, separator, raw_value = item.partition("=")
334
+ if separator and key.strip():
335
+ parsed[key.strip()] = raw_value.strip()
336
+ return parsed or value
337
+
338
+
339
+ class MCPArguments(StrictRequestModel):
340
+ """Base class for MCP tool arguments."""
341
+
342
+ tool_name: ClassVar[ToolName]
343
+
344
+ def to_tool_call(self) -> MCPToolCall:
345
+ return MCPToolCall(name=self.tool_name, arguments=self.to_payload())
346
+
347
+
348
+ class SearchArguments(MCPArguments):
349
+ """MCP search tool arguments."""
350
+
351
+ tool_name = ToolName.SEARCH
352
+
353
+ query: NonEmptyString
354
+ domain: Domain | None = None
355
+ sub_domain: SubDomainName | None = None
356
+ sub_domain_params: SubDomainParams | None = None
357
+ max_results: int | None = Field(default=None, ge=1, le=10)
358
+
359
+ @classmethod
360
+ def from_input(
361
+ cls, arguments: Self | str | Mapping[str, Any], **kwargs: Any
362
+ ) -> SearchArguments:
363
+ if isinstance(arguments, SearchArguments):
364
+ if kwargs:
365
+ msg = "keyword arguments cannot be combined with SearchArguments"
366
+ raise ValueError(msg)
367
+ return arguments
368
+ if isinstance(arguments, str):
369
+ return cls.model_validate({"query": arguments, **kwargs})
370
+ return cls.model_validate({**dict(arguments), **kwargs})
371
+
372
+ @model_validator(mode="after")
373
+ def validate_vertical_shape(self) -> Self:
374
+ if self.domain is None:
375
+ if self.sub_domain is not None or self.sub_domain_params is not None:
376
+ msg = "sub_domain and sub_domain_params require domain"
377
+ raise ValueError(msg)
378
+ return self
379
+ if self.sub_domain is None:
380
+ msg = "sub_domain is required when domain is provided"
381
+ raise ValueError(msg)
382
+ return self
383
+
384
+
385
+ class BatchSearchArguments(MCPArguments):
386
+ """MCP batch_search tool arguments."""
387
+
388
+ tool_name = ToolName.BATCH_SEARCH
389
+
390
+ queries: tuple[SearchArguments, ...] = Field(min_length=1, max_length=5)
391
+
392
+ @classmethod
393
+ def from_queries(
394
+ cls,
395
+ queries: Sequence[SearchArgumentInput],
396
+ *,
397
+ domain: Domain | str | None = None,
398
+ sub_domain: str | None = None,
399
+ sub_domain_params: SubDomainParams
400
+ | Mapping[str, JsonValue]
401
+ | str
402
+ | None = None,
403
+ ) -> Self:
404
+ shared_params = (
405
+ SubDomainParams.model_validate(sub_domain_params)
406
+ if sub_domain_params is not None
407
+ else None
408
+ )
409
+ items: list[SearchArguments] = []
410
+ for query in queries:
411
+ values: dict[str, Any]
412
+ if isinstance(query, SearchArguments):
413
+ values = query.to_payload()
414
+ elif isinstance(query, str):
415
+ values = {"query": query}
416
+ else:
417
+ values = dict(query)
418
+
419
+ if domain is not None and values.get("domain") is None:
420
+ values["domain"] = str(domain)
421
+ if sub_domain is not None and values.get("sub_domain") is None:
422
+ values["sub_domain"] = sub_domain
423
+ if shared_params is not None and values.get("sub_domain_params") is None:
424
+ values["sub_domain_params"] = shared_params.root
425
+ items.append(SearchArguments.model_validate(values))
426
+ return cls(queries=tuple(items))
427
+
428
+
429
+ class GetSubDomainsArguments(MCPArguments):
430
+ """MCP get_sub_domains tool arguments."""
431
+
432
+ tool_name = ToolName.GET_SUB_DOMAINS
433
+
434
+ domain: Domain | None = None
435
+ domains: tuple[Domain, ...] | None = Field(default=None, min_length=1, max_length=5)
436
+
437
+ @classmethod
438
+ def from_selector(
439
+ cls,
440
+ *,
441
+ domain: Domain | str | None = None,
442
+ domains: Sequence[Domain | str] | None = None,
443
+ ) -> Self:
444
+ return cls.model_validate(
445
+ {
446
+ "domain": domain,
447
+ "domains": tuple(domains) if domains is not None else None,
448
+ },
449
+ )
450
+
451
+ @model_validator(mode="after")
452
+ def validate_domain_selector(self) -> Self:
453
+ has_domain = self.domain is not None
454
+ has_domains = self.domains is not None
455
+ if has_domain == has_domains:
456
+ msg = "provide exactly one of domain or domains"
457
+ raise ValueError(msg)
458
+ return self
459
+
460
+
461
+ class ExtractArguments(MCPArguments):
462
+ """MCP extract tool arguments."""
463
+
464
+ tool_name = ToolName.EXTRACT
465
+
466
+ url: HttpUrl
467
+
468
+ @classmethod
469
+ def from_url(cls, url: str) -> Self:
470
+ return cls.model_validate({"url": url})
471
+
472
+
473
+ class MCPContentBlock(ResponseModel):
474
+ """One MCP content block."""
475
+
476
+ type: str
477
+ text: str | None = None
478
+
479
+
480
+ class MCPToolResult(ResponseModel):
481
+ """MCP tools/call result."""
482
+
483
+ content: tuple[MCPContentBlock, ...] = ()
484
+ is_error: bool = Field(default=False, alias="isError")
485
+
486
+ @computed_field
487
+ @property
488
+ def text(self) -> str:
489
+ return "\n".join(
490
+ block.text for block in self.content if block.type == "text" and block.text
491
+ )
492
+
493
+ def raise_if_error(self) -> None:
494
+ if not self.is_error:
495
+ return
496
+
497
+ raise JSONRPCError(self.text or "MCP tool returned an error")
498
+
499
+
500
+ class JSONRPCErrorObject(ResponseModel):
501
+ code: int
502
+ message: str
503
+ data: JsonValue | None = None
504
+
505
+ def raise_error(self) -> None:
506
+ raise JSONRPCError(self.message, code=self.code, data=self.data)
507
+
508
+
509
+ class JSONRPCResponse[DataT](ResponseModel):
510
+ jsonrpc: Literal["2.0"] = "2.0"
511
+ id: int | str | None = None
512
+ result: DataT | None = None
513
+ error: JSONRPCErrorObject | None = None
514
+
515
+ @model_validator(mode="after")
516
+ def validate_result_or_error(self) -> Self:
517
+ if (self.result is None) == (self.error is None):
518
+ msg = "JSON-RPC response must contain exactly one of result or error"
519
+ raise ValueError(msg)
520
+ return self
521
+
522
+ def unwrap(self) -> DataT:
523
+ if self.error is not None:
524
+ self.error.raise_error()
525
+ if self.result is None:
526
+ msg = "MCP response is missing result"
527
+ raise ResponseValidationError(msg, payload=self.model_dump(mode="json"))
528
+ return self.result
529
+
530
+
531
+ class MCPToolResponse(JSONRPCResponse[MCPToolResult]):
532
+ """Successful MCP tools/call response."""
533
+
534
+ @classmethod
535
+ def from_payload(cls, payload: Any) -> MCPToolResult:
536
+ try:
537
+ result = cls.model_validate(payload).unwrap()
538
+ except ValidationError as exc:
539
+ msg = "MCP response does not match JSON-RPC tools/call schema"
540
+ raise ResponseValidationError(msg, payload=payload) from exc
541
+ result.raise_if_error()
542
+ return result
543
+
544
+
545
+ class JSONRPCToolCallParams(StrictRequestModel):
546
+ name: ToolName
547
+ arguments: JsonObject
548
+
549
+
550
+ class JSONRPCToolCallRequest(StrictRequestModel):
551
+ jsonrpc: Literal["2.0"] = "2.0"
552
+ id: int
553
+ method: Literal["tools/call"] = "tools/call"
554
+ params: JSONRPCToolCallParams
555
+
556
+
557
+ class MCPToolCall(StrictRequestModel):
558
+ """MCP tool call before JSON-RPC wrapping."""
559
+
560
+ name: ToolName
561
+ arguments: JsonObject = Field(default_factory=dict)
562
+
563
+ @classmethod
564
+ def from_arguments(
565
+ cls,
566
+ name: ToolName | str,
567
+ arguments: MCPArguments | Mapping[str, JsonValue] | None = None,
568
+ ) -> MCPToolCall:
569
+ if isinstance(arguments, MCPArguments):
570
+ return arguments.to_tool_call()
571
+ return cls.model_validate({"name": name, "arguments": dict(arguments or {})})
572
+
573
+ def to_request(self, request_id: int) -> JSONRPCToolCallRequest:
574
+ return JSONRPCToolCallRequest(
575
+ id=request_id,
576
+ params=JSONRPCToolCallParams(
577
+ name=self.name,
578
+ arguments=self.arguments,
579
+ ),
580
+ )
File without changes