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.
- anysearch_py-0.1.0/LICENSE +21 -0
- anysearch_py-0.1.0/PKG-INFO +93 -0
- anysearch_py-0.1.0/README.md +77 -0
- anysearch_py-0.1.0/pyproject.toml +82 -0
- anysearch_py-0.1.0/src/anysearch_py/__init__.py +72 -0
- anysearch_py-0.1.0/src/anysearch_py/client.py +383 -0
- anysearch_py-0.1.0/src/anysearch_py/exceptions.py +62 -0
- anysearch_py-0.1.0/src/anysearch_py/models.py +580 -0
- anysearch_py-0.1.0/src/anysearch_py/py.typed +0 -0
|
@@ -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
|