tinyfish 0.2.4__tar.gz → 0.2.5__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.
- tinyfish-0.2.5/AGENTS.md +1 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/PKG-INFO +1 -1
- {tinyfish-0.2.4 → tinyfish-0.2.5}/RELEASE.md +5 -5
- {tinyfish-0.2.4 → tinyfish-0.2.5}/pyproject.toml +1 -1
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/__init__.py +23 -2
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/agent/types.py +1 -1
- tinyfish-0.2.5/src/tinyfish/browser/__init__.py +86 -0
- tinyfish-0.2.5/src/tinyfish/browser/types.py +24 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/client.py +15 -0
- tinyfish-0.2.5/src/tinyfish/fetch/__init__.py +99 -0
- tinyfish-0.2.5/src/tinyfish/fetch/types.py +40 -0
- tinyfish-0.2.5/src/tinyfish/search/__init__.py +79 -0
- tinyfish-0.2.5/src/tinyfish/search/types.py +21 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/test_agent.py +3 -3
- tinyfish-0.2.5/tests/test_browser.py +141 -0
- tinyfish-0.2.5/tests/test_fetch.py +267 -0
- tinyfish-0.2.5/tests/test_search.py +229 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/.gitignore +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/.pre-commit-config.yaml +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/CLAUDE.md +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/README.md +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/exceptions-and-errors-guide.md +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/internal/api-integration-header.md +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/internal/exceptions-and-errors-guide.md +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/internal/publishing-private-guide.md +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/pagination-guide.md +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/proxy-and-browser-profiles.md +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/streaming-guide.md +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/__init__.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/client/__init__.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/client/_base.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/client/async_.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/client/sync.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/exceptions.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/resource.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/sse_parser.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/agent/__init__.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/py.typed +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/runs/__init__.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/runs/types.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/__init__.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/conftest.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/test_client.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/test_errors.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/test_runs.py +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/testing_guide.md +0 -0
- {tinyfish-0.2.4 → tinyfish-0.2.5}/uv.lock +0 -0
tinyfish-0.2.5/AGENTS.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
CLAUDE.md
|
|
@@ -33,13 +33,13 @@ Commit and merge to main.
|
|
|
33
33
|
### Step 2: Create a GitHub Release
|
|
34
34
|
|
|
35
35
|
1. Go to the `ux-labs` repo on GitHub → **Releases** → **Draft a new release**
|
|
36
|
-
2. Set the tag to match the version exactly, prefixed with `v`:
|
|
37
|
-
- Version `0.2.0` → tag `v0.2.0`
|
|
36
|
+
2. Set the tag to match the version exactly, prefixed with `sdk-py/v`:
|
|
37
|
+
- Version `0.2.0` → tag `sdk-py/v0.2.0`
|
|
38
38
|
3. Set the target to `main`
|
|
39
39
|
4. Write release notes summarizing what changed
|
|
40
40
|
5. Click **Publish release**
|
|
41
41
|
|
|
42
|
-
The workflow validates that the
|
|
42
|
+
The CD workflow (`CD_sdk_python.yml`) only triggers on tags prefixed with `sdk-py/v`. It validates that the version portion of the tag matches `pyproject.toml` — if they don't match, the build fails before anything is published.
|
|
43
43
|
|
|
44
44
|
### Step 3: Monitor the workflow
|
|
45
45
|
|
|
@@ -56,13 +56,13 @@ After the workflow completes, verify the release:
|
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
58
|
pip install tinyfish==0.2.0
|
|
59
|
-
python -c "from tinyfish import
|
|
59
|
+
python -c "from tinyfish import TinyFish; print('ok')"
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
## Troubleshooting
|
|
63
63
|
|
|
64
64
|
**Tag does not match pyproject.toml version**
|
|
65
|
-
The build job validates that the git tag (e.g., `v0.2.0`) matches the version in `pyproject.toml` (e.g., `0.2.0`). Fix by updating `pyproject.toml` to match the tag before publishing, then delete and recreate the release.
|
|
65
|
+
The build job validates that the git tag (e.g., `sdk-py/v0.2.0`) matches the version in `pyproject.toml` (e.g., `0.2.0`). Fix by updating `pyproject.toml` to match the tag before publishing, then delete and recreate the release.
|
|
66
66
|
|
|
67
67
|
**403 / authentication error**
|
|
68
68
|
Verify that `PYPI_API_TOKEN` has been added to the `ux-labs` repo secrets in `github-control`. The token must exist as a GitHub Actions secret before the workflow can publish.
|
|
@@ -26,8 +26,6 @@ from ._utils.exceptions import (
|
|
|
26
26
|
|
|
27
27
|
# Agent resource
|
|
28
28
|
from .agent import AgentStream, AsyncAgentStream
|
|
29
|
-
|
|
30
|
-
# Agent types
|
|
31
29
|
from .agent.types import (
|
|
32
30
|
AgentRunAsyncResponse,
|
|
33
31
|
AgentRunResponse,
|
|
@@ -42,7 +40,16 @@ from .agent.types import (
|
|
|
42
40
|
StartedEvent,
|
|
43
41
|
StreamingUrlEvent,
|
|
44
42
|
)
|
|
43
|
+
|
|
44
|
+
# Browser types
|
|
45
|
+
from .browser.types import BrowserSession, BrowserSessionCreateParams
|
|
45
46
|
from .client import AsyncTinyFish, TinyFish
|
|
47
|
+
from .fetch.types import (
|
|
48
|
+
FetchError,
|
|
49
|
+
FetchFormat,
|
|
50
|
+
FetchResponse,
|
|
51
|
+
FetchResult,
|
|
52
|
+
)
|
|
46
53
|
|
|
47
54
|
# Runs types
|
|
48
55
|
from .runs.types import (
|
|
@@ -55,6 +62,9 @@ from .runs.types import (
|
|
|
55
62
|
SortDirection,
|
|
56
63
|
)
|
|
57
64
|
|
|
65
|
+
# Search types
|
|
66
|
+
from .search.types import SearchQueryResponse, SearchResult
|
|
67
|
+
|
|
58
68
|
__version__ = version("tinyfish")
|
|
59
69
|
|
|
60
70
|
__all__ = [
|
|
@@ -80,6 +90,9 @@ __all__ = [
|
|
|
80
90
|
# Agent resource
|
|
81
91
|
"AgentStream",
|
|
82
92
|
"AsyncAgentStream",
|
|
93
|
+
# Browser types
|
|
94
|
+
"BrowserSession",
|
|
95
|
+
"BrowserSessionCreateParams",
|
|
83
96
|
# Agent types
|
|
84
97
|
"EventType",
|
|
85
98
|
"BrowserProfile",
|
|
@@ -93,6 +106,14 @@ __all__ = [
|
|
|
93
106
|
"HeartbeatEvent",
|
|
94
107
|
"CompleteEvent",
|
|
95
108
|
"AgentRunWithStreamingResponse",
|
|
109
|
+
# Fetch types
|
|
110
|
+
"FetchFormat",
|
|
111
|
+
"FetchResult",
|
|
112
|
+
"FetchError",
|
|
113
|
+
"FetchResponse",
|
|
114
|
+
# Search types
|
|
115
|
+
"SearchResult",
|
|
116
|
+
"SearchQueryResponse",
|
|
96
117
|
# Runs types
|
|
97
118
|
"ErrorCategory",
|
|
98
119
|
"SortDirection",
|
|
@@ -186,7 +186,7 @@ class CompleteEvent(BaseModel):
|
|
|
186
186
|
status: RunStatus = Field(..., description="Final status of the automation")
|
|
187
187
|
timestamp: datetime = Field(..., description="Timestamp of the event")
|
|
188
188
|
result_json: dict[str, object] | None = Field(
|
|
189
|
-
None, alias="
|
|
189
|
+
None, alias="result", description="Structured JSON result extracted from the automation"
|
|
190
190
|
)
|
|
191
191
|
error: RunError | None = Field(None, description="Error details if the run failed. None if succeeded.")
|
|
192
192
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Browser resource for creating remote browser sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tinyfish._utils.client import BaseAsyncAPIClient, BaseSyncAPIClient
|
|
6
|
+
from tinyfish._utils.resource import BaseAsyncAPIResource, BaseSyncAPIResource
|
|
7
|
+
|
|
8
|
+
from .types import BrowserSession, BrowserSessionCreateParams
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BrowserSessionsResource(BaseSyncAPIResource):
|
|
12
|
+
"""Create remote browser sessions (sync)."""
|
|
13
|
+
|
|
14
|
+
def create(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
url: str | None = None,
|
|
18
|
+
timeout_seconds: int | None = None,
|
|
19
|
+
) -> BrowserSession:
|
|
20
|
+
"""Create a new remote browser session.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
url: Target URL for the browser session. If omitted, browser starts at about:blank.
|
|
24
|
+
timeout_seconds: Inactivity timeout in seconds. If omitted or exceeds plan max,
|
|
25
|
+
the plan max is used.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
BrowserSession with session_id, cdp_url, and base_url.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
BadRequestError: Invalid parameters.
|
|
32
|
+
AuthenticationError: Invalid API key.
|
|
33
|
+
"""
|
|
34
|
+
params = BrowserSessionCreateParams(url=url, timeout_seconds=timeout_seconds)
|
|
35
|
+
body = params.model_dump(exclude_none=True)
|
|
36
|
+
return self._post("/v1/browser", json=body, cast_to=BrowserSession)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AsyncBrowserSessionsResource(BaseAsyncAPIResource):
|
|
40
|
+
"""Create remote browser sessions (async)."""
|
|
41
|
+
|
|
42
|
+
async def create(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
url: str | None = None,
|
|
46
|
+
timeout_seconds: int | None = None,
|
|
47
|
+
) -> BrowserSession:
|
|
48
|
+
"""Create a new remote browser session.
|
|
49
|
+
|
|
50
|
+
Async version of `BrowserSessionsResource.create()`.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
url: Target URL for the browser session. If omitted, browser starts at about:blank.
|
|
54
|
+
timeout_seconds: Inactivity timeout in seconds. If omitted or exceeds plan max,
|
|
55
|
+
the plan max is used.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
BrowserSession with session_id, cdp_url, and base_url.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
BadRequestError: Invalid parameters.
|
|
62
|
+
AuthenticationError: Invalid API key.
|
|
63
|
+
"""
|
|
64
|
+
params = BrowserSessionCreateParams(url=url, timeout_seconds=timeout_seconds)
|
|
65
|
+
body = params.model_dump(exclude_none=True)
|
|
66
|
+
return await self._post("/v1/browser", json=body, cast_to=BrowserSession)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BrowserResource(BaseSyncAPIResource):
|
|
70
|
+
"""Browser API namespace (sync)."""
|
|
71
|
+
|
|
72
|
+
sessions: BrowserSessionsResource
|
|
73
|
+
|
|
74
|
+
def __init__(self, client: BaseSyncAPIClient) -> None:
|
|
75
|
+
super().__init__(client)
|
|
76
|
+
self.sessions = BrowserSessionsResource(client)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AsyncBrowserResource(BaseAsyncAPIResource):
|
|
80
|
+
"""Browser API namespace (async)."""
|
|
81
|
+
|
|
82
|
+
sessions: AsyncBrowserSessionsResource
|
|
83
|
+
|
|
84
|
+
def __init__(self, client: BaseAsyncAPIClient) -> None:
|
|
85
|
+
super().__init__(client)
|
|
86
|
+
self.sessions = AsyncBrowserSessionsResource(client)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Browser API request/response types."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BrowserSessionCreateParams(BaseModel):
|
|
7
|
+
"""Parameters for `browser.sessions.create()`."""
|
|
8
|
+
|
|
9
|
+
url: str | None = Field(
|
|
10
|
+
None,
|
|
11
|
+
description="Target URL for the browser session. If omitted, browser starts at about:blank.",
|
|
12
|
+
)
|
|
13
|
+
timeout_seconds: int | None = Field(
|
|
14
|
+
None,
|
|
15
|
+
description="Inactivity timeout in seconds. If omitted or exceeds plan max, the plan max is used.",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BrowserSession(BaseModel):
|
|
20
|
+
"""Response from creating a browser session."""
|
|
21
|
+
|
|
22
|
+
session_id: str = Field(..., description="Unique session identifier")
|
|
23
|
+
cdp_url: str = Field(..., description="CDP WebSocket URL for direct browser connection")
|
|
24
|
+
base_url: str = Field(..., description="HTTPS base URL for the browser session")
|
|
@@ -4,7 +4,10 @@ from tinyfish._utils.client import BaseAsyncAPIClient, BaseSyncAPIClient
|
|
|
4
4
|
from tinyfish._utils.client._base import _DEFAULT_TIMEOUT
|
|
5
5
|
|
|
6
6
|
from .agent import AgentResource, AsyncAgentResource
|
|
7
|
+
from .browser import AsyncBrowserResource, BrowserResource
|
|
8
|
+
from .fetch import AsyncFetchResource, FetchResource
|
|
7
9
|
from .runs import AsyncRunsResource, RunsResource
|
|
10
|
+
from .search import AsyncSearchResource, SearchResource
|
|
8
11
|
|
|
9
12
|
__all__ = ["TinyFish", "AsyncTinyFish"]
|
|
10
13
|
|
|
@@ -21,7 +24,10 @@ class TinyFish(BaseSyncAPIClient):
|
|
|
21
24
|
# __init__ assignments below — which means no autocomplete for users who just
|
|
22
25
|
# did `pip install tinyfish`.
|
|
23
26
|
agent: AgentResource
|
|
27
|
+
browser: BrowserResource
|
|
28
|
+
fetch: FetchResource
|
|
24
29
|
runs: RunsResource
|
|
30
|
+
search: SearchResource
|
|
25
31
|
|
|
26
32
|
def __init__(
|
|
27
33
|
self,
|
|
@@ -42,7 +48,10 @@ class TinyFish(BaseSyncAPIClient):
|
|
|
42
48
|
)
|
|
43
49
|
|
|
44
50
|
self.agent = AgentResource(self)
|
|
51
|
+
self.browser = BrowserResource(self)
|
|
52
|
+
self.fetch = FetchResource(self)
|
|
45
53
|
self.runs = RunsResource(self)
|
|
54
|
+
self.search = SearchResource(self)
|
|
46
55
|
|
|
47
56
|
|
|
48
57
|
class AsyncTinyFish(BaseAsyncAPIClient):
|
|
@@ -52,7 +61,10 @@ class AsyncTinyFish(BaseAsyncAPIClient):
|
|
|
52
61
|
# checkers recognise these as AsyncAgentResource / AsyncRunsResource (not
|
|
53
62
|
# the sync variants) when resolving types from an installed package.
|
|
54
63
|
agent: AsyncAgentResource
|
|
64
|
+
browser: AsyncBrowserResource
|
|
65
|
+
fetch: AsyncFetchResource
|
|
55
66
|
runs: AsyncRunsResource
|
|
67
|
+
search: AsyncSearchResource
|
|
56
68
|
|
|
57
69
|
def __init__(
|
|
58
70
|
self,
|
|
@@ -73,4 +85,7 @@ class AsyncTinyFish(BaseAsyncAPIClient):
|
|
|
73
85
|
)
|
|
74
86
|
|
|
75
87
|
self.agent = AsyncAgentResource(self)
|
|
88
|
+
self.browser = AsyncBrowserResource(self)
|
|
89
|
+
self.fetch = AsyncFetchResource(self)
|
|
76
90
|
self.runs = AsyncRunsResource(self)
|
|
91
|
+
self.search = AsyncSearchResource(self)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Fetch resource for extracting clean content from URLs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tinyfish._utils.exceptions import SDKError
|
|
6
|
+
from tinyfish._utils.resource import BaseAsyncAPIResource, BaseSyncAPIResource
|
|
7
|
+
|
|
8
|
+
from .types import FetchFormat, FetchResponse
|
|
9
|
+
|
|
10
|
+
MIN_URLS = 1
|
|
11
|
+
MAX_URLS = 10
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FetchResource(BaseSyncAPIResource):
|
|
15
|
+
"""Fetch and extract clean content from URLs."""
|
|
16
|
+
|
|
17
|
+
def get_contents(
|
|
18
|
+
self,
|
|
19
|
+
urls: list[str],
|
|
20
|
+
*,
|
|
21
|
+
format: FetchFormat | None = None,
|
|
22
|
+
links: bool | None = None,
|
|
23
|
+
image_links: bool | None = None,
|
|
24
|
+
) -> FetchResponse:
|
|
25
|
+
"""Fetch and extract content from one or more URLs.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
urls: List of URLs to fetch (1-10 items).
|
|
29
|
+
format: Output format — "markdown", "html", or "json".
|
|
30
|
+
links: Whether to extract links from the page.
|
|
31
|
+
image_links: Whether to extract image links from the page.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
FetchResponse with results and errors lists.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
SDKError: urls is empty or has more than 10 items.
|
|
38
|
+
AuthenticationError: Invalid API key.
|
|
39
|
+
"""
|
|
40
|
+
_validate_urls(urls)
|
|
41
|
+
body = _build_body(urls, format=format, links=links, image_links=image_links)
|
|
42
|
+
return self._post("/v1/fetch", json=body, cast_to=FetchResponse)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AsyncFetchResource(BaseAsyncAPIResource):
|
|
46
|
+
"""Async fetch and extract clean content from URLs."""
|
|
47
|
+
|
|
48
|
+
async def get_contents(
|
|
49
|
+
self,
|
|
50
|
+
urls: list[str],
|
|
51
|
+
*,
|
|
52
|
+
format: FetchFormat | None = None,
|
|
53
|
+
links: bool | None = None,
|
|
54
|
+
image_links: bool | None = None,
|
|
55
|
+
) -> FetchResponse:
|
|
56
|
+
"""Fetch and extract content from one or more URLs.
|
|
57
|
+
|
|
58
|
+
Async version of `FetchResource.get_contents()`.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
urls: List of URLs to fetch (1-10 items).
|
|
62
|
+
format: Output format — "markdown", "html", or "json".
|
|
63
|
+
links: Whether to extract links from the page.
|
|
64
|
+
image_links: Whether to extract image links from the page.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
FetchResponse with results and errors lists.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
SDKError: urls is empty or has more than 10 items.
|
|
71
|
+
AuthenticationError: Invalid API key.
|
|
72
|
+
"""
|
|
73
|
+
_validate_urls(urls)
|
|
74
|
+
body = _build_body(urls, format=format, links=links, image_links=image_links)
|
|
75
|
+
return await self._post("/v1/fetch", json=body, cast_to=FetchResponse)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _validate_urls(urls: list[str]) -> None:
|
|
79
|
+
if len(urls) < MIN_URLS:
|
|
80
|
+
raise SDKError("urls must be a non-empty array")
|
|
81
|
+
if len(urls) > MAX_URLS:
|
|
82
|
+
raise SDKError("urls must contain at most 10 items")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _build_body(
|
|
86
|
+
urls: list[str],
|
|
87
|
+
*,
|
|
88
|
+
format: FetchFormat | None,
|
|
89
|
+
links: bool | None,
|
|
90
|
+
image_links: bool | None,
|
|
91
|
+
) -> dict[str, object]:
|
|
92
|
+
body: dict[str, object] = {"urls": urls}
|
|
93
|
+
if format is not None:
|
|
94
|
+
body["format"] = format
|
|
95
|
+
if links is not None:
|
|
96
|
+
body["links"] = links
|
|
97
|
+
if image_links is not None:
|
|
98
|
+
body["image_links"] = image_links
|
|
99
|
+
return body
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Fetch API request/response types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
FetchFormat = Literal["markdown", "html", "json"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FetchResult(BaseModel):
|
|
13
|
+
"""A single successfully fetched URL result."""
|
|
14
|
+
|
|
15
|
+
url: str = Field(..., description="The original requested URL")
|
|
16
|
+
final_url: str | None = Field(None, description="The final URL after redirects")
|
|
17
|
+
title: str | None = Field(None, description="Page title")
|
|
18
|
+
description: str | None = Field(None, description="Page meta description")
|
|
19
|
+
language: str | None = Field(None, description="Detected language code")
|
|
20
|
+
author: str | None = Field(None, description="Content author")
|
|
21
|
+
published_date: str | None = Field(None, description="Publication date")
|
|
22
|
+
text: str | None = Field(None, description="Extracted content in the requested format")
|
|
23
|
+
format: FetchFormat = Field(..., description="Output format used")
|
|
24
|
+
links: list[str] = Field(default_factory=list, description="Extracted links")
|
|
25
|
+
image_links: list[str] = Field(default_factory=list, description="Extracted image links")
|
|
26
|
+
latency_ms: float | None = Field(None, description="Fetch latency in milliseconds")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FetchError(BaseModel):
|
|
30
|
+
"""A single failed fetch result."""
|
|
31
|
+
|
|
32
|
+
url: str = Field(..., description="The URL that failed")
|
|
33
|
+
error: str = Field(..., description="Error message")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FetchResponse(BaseModel):
|
|
37
|
+
"""Response from POST /v1/fetch."""
|
|
38
|
+
|
|
39
|
+
results: list[FetchResult] = Field(..., description="Successfully fetched results")
|
|
40
|
+
errors: list[FetchError] = Field(..., description="Failed fetch results")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Search API resource."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tinyfish._utils.resource import BaseAsyncAPIResource, BaseSyncAPIResource
|
|
6
|
+
|
|
7
|
+
from .types import SearchQueryResponse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SearchResource(BaseSyncAPIResource):
|
|
11
|
+
"""Search the web using TinyFish's search API."""
|
|
12
|
+
|
|
13
|
+
def query(
|
|
14
|
+
self,
|
|
15
|
+
query: str,
|
|
16
|
+
*,
|
|
17
|
+
location: str | None = None,
|
|
18
|
+
language: str | None = None,
|
|
19
|
+
) -> SearchQueryResponse:
|
|
20
|
+
"""Execute a web search query.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
query: The search query string.
|
|
24
|
+
location: Optional location to scope results (e.g. "United States").
|
|
25
|
+
language: Optional language code (e.g. "en").
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
SearchQueryResponse with query echo, results list, and total count.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: query is empty or whitespace.
|
|
32
|
+
AuthenticationError: Invalid API key.
|
|
33
|
+
RateLimitError: Too many requests.
|
|
34
|
+
"""
|
|
35
|
+
if not query or not query.strip():
|
|
36
|
+
raise ValueError("query must be a non-empty string")
|
|
37
|
+
params: dict[str, str] = {"query": query}
|
|
38
|
+
if location is not None:
|
|
39
|
+
params["location"] = location
|
|
40
|
+
if language is not None:
|
|
41
|
+
params["language"] = language
|
|
42
|
+
return self._get("/v1/search", params=params, cast_to=SearchQueryResponse)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AsyncSearchResource(BaseAsyncAPIResource):
|
|
46
|
+
"""Async search the web using TinyFish's search API."""
|
|
47
|
+
|
|
48
|
+
async def query(
|
|
49
|
+
self,
|
|
50
|
+
query: str,
|
|
51
|
+
*,
|
|
52
|
+
location: str | None = None,
|
|
53
|
+
language: str | None = None,
|
|
54
|
+
) -> SearchQueryResponse:
|
|
55
|
+
"""Execute a web search query.
|
|
56
|
+
|
|
57
|
+
Async version of `SearchResource.query()`.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
query: The search query string.
|
|
61
|
+
location: Optional location to scope results (e.g. "United States").
|
|
62
|
+
language: Optional language code (e.g. "en").
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
SearchQueryResponse with query echo, results list, and total count.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: query is empty or whitespace.
|
|
69
|
+
AuthenticationError: Invalid API key.
|
|
70
|
+
RateLimitError: Too many requests.
|
|
71
|
+
"""
|
|
72
|
+
if not query or not query.strip():
|
|
73
|
+
raise ValueError("query must be a non-empty string")
|
|
74
|
+
params: dict[str, str] = {"query": query}
|
|
75
|
+
if location is not None:
|
|
76
|
+
params["location"] = location
|
|
77
|
+
if language is not None:
|
|
78
|
+
params["language"] = language
|
|
79
|
+
return await self._get("/v1/search", params=params, cast_to=SearchQueryResponse)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Search API request/response types."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SearchResult(BaseModel):
|
|
7
|
+
"""A single search result item."""
|
|
8
|
+
|
|
9
|
+
position: int = Field(..., description="Position in the search results")
|
|
10
|
+
site_name: str = Field(..., description="Domain name of the result")
|
|
11
|
+
snippet: str = Field(..., description="Text snippet from the result")
|
|
12
|
+
title: str = Field(..., description="Title of the result")
|
|
13
|
+
url: str = Field(..., description="URL of the result")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SearchQueryResponse(BaseModel):
|
|
17
|
+
"""Response from a search query."""
|
|
18
|
+
|
|
19
|
+
query: str = Field(..., description="The search query that was executed")
|
|
20
|
+
results: list[SearchResult] = Field(..., description="List of search results")
|
|
21
|
+
total_results: int = Field(..., description="Total number of results found")
|
|
@@ -56,7 +56,7 @@ _EV_COMPLETE_OK: dict[str, Any] = {
|
|
|
56
56
|
"runId": _RUN_ID,
|
|
57
57
|
"status": "COMPLETED",
|
|
58
58
|
"timestamp": _TS,
|
|
59
|
-
"
|
|
59
|
+
"result": {"price": "$999"},
|
|
60
60
|
"error": None,
|
|
61
61
|
}
|
|
62
62
|
_EV_COMPLETE_FAIL: dict[str, Any] = {
|
|
@@ -64,7 +64,7 @@ _EV_COMPLETE_FAIL: dict[str, Any] = {
|
|
|
64
64
|
"runId": _RUN_ID,
|
|
65
65
|
"status": "FAILED",
|
|
66
66
|
"timestamp": _TS,
|
|
67
|
-
"
|
|
67
|
+
"result": None,
|
|
68
68
|
"error": {"message": "something went wrong", "category": "AGENT_FAILURE"},
|
|
69
69
|
}
|
|
70
70
|
_EV_COMPLETE_CANCELLED: dict[str, Any] = {
|
|
@@ -72,7 +72,7 @@ _EV_COMPLETE_CANCELLED: dict[str, Any] = {
|
|
|
72
72
|
"runId": _RUN_ID,
|
|
73
73
|
"status": "CANCELLED",
|
|
74
74
|
"timestamp": _TS,
|
|
75
|
-
"
|
|
75
|
+
"result": None,
|
|
76
76
|
"error": None,
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Tests for browser.sessions.create() — sync and async."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
from respx import MockRouter
|
|
11
|
+
|
|
12
|
+
from tests.conftest import BASE_URL
|
|
13
|
+
from tinyfish import AsyncTinyFish, AuthenticationError, BadRequestError, InternalServerError, TinyFish
|
|
14
|
+
from tinyfish.browser.types import BrowserSession
|
|
15
|
+
|
|
16
|
+
# ─── Constants ────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
_BROWSER_PATH = "/v1/browser"
|
|
19
|
+
_SESSION_ID = "tf-a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
|
20
|
+
_CDP_URL = "wss://tetra-abc123.cluster.example.com/cdp"
|
|
21
|
+
_BASE_URL = "https://tetra-abc123.cluster.example.com"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _session_response(**overrides: Any) -> dict[str, Any]:
|
|
25
|
+
base: dict[str, Any] = {
|
|
26
|
+
"session_id": _SESSION_ID,
|
|
27
|
+
"cdp_url": _CDP_URL,
|
|
28
|
+
"base_url": _BASE_URL,
|
|
29
|
+
}
|
|
30
|
+
return {**base, **overrides}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ─── Param cases shared by sync and async ─────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
_PARAM_CASES: list[tuple[str, dict[str, Any], dict[str, Any]]] = [
|
|
36
|
+
("url_only", {"url": "https://example.com"}, {"url": "https://example.com"}),
|
|
37
|
+
("timeout_only", {"timeout_seconds": 300}, {"timeout_seconds": 300}),
|
|
38
|
+
(
|
|
39
|
+
"both",
|
|
40
|
+
{"url": "https://example.com", "timeout_seconds": 600},
|
|
41
|
+
{"url": "https://example.com", "timeout_seconds": 600},
|
|
42
|
+
),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
_ERROR_CASES: list[tuple[int, type[Exception]]] = [
|
|
46
|
+
(400, BadRequestError),
|
|
47
|
+
(401, AuthenticationError),
|
|
48
|
+
(500, InternalServerError),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
53
|
+
# browser.sessions.create() — sync
|
|
54
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
58
|
+
def test_create_returns_browser_session(client: TinyFish, respx_mock: MockRouter):
|
|
59
|
+
respx_mock.post(_BROWSER_PATH).mock(return_value=httpx.Response(201, json=_session_response()))
|
|
60
|
+
result = client.browser.sessions.create()
|
|
61
|
+
assert isinstance(result, BrowserSession)
|
|
62
|
+
assert result.session_id == _SESSION_ID
|
|
63
|
+
assert result.cdp_url == _CDP_URL
|
|
64
|
+
assert result.base_url == _BASE_URL
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.parametrize("name,kwargs,expected", _PARAM_CASES, ids=[c[0] for c in _PARAM_CASES])
|
|
68
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
69
|
+
def test_create_sends_params(name: str, kwargs: dict, expected: dict, client: TinyFish, respx_mock: MockRouter):
|
|
70
|
+
route = respx_mock.post(_BROWSER_PATH).mock(return_value=httpx.Response(201, json=_session_response()))
|
|
71
|
+
client.browser.sessions.create(**kwargs)
|
|
72
|
+
parsed = json.loads(route.calls[0].request.content)
|
|
73
|
+
for key, value in expected.items():
|
|
74
|
+
assert parsed[key] == value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
78
|
+
def test_create_omits_none_values(client: TinyFish, respx_mock: MockRouter):
|
|
79
|
+
route = respx_mock.post(_BROWSER_PATH).mock(return_value=httpx.Response(201, json=_session_response()))
|
|
80
|
+
client.browser.sessions.create()
|
|
81
|
+
parsed = json.loads(route.calls[0].request.content)
|
|
82
|
+
assert "url" not in parsed
|
|
83
|
+
assert "timeout_seconds" not in parsed
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.parametrize("status,error_class", _ERROR_CASES, ids=[str(c[0]) for c in _ERROR_CASES])
|
|
87
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
88
|
+
def test_create_error_status(status: int, error_class: type, client: TinyFish, respx_mock: MockRouter):
|
|
89
|
+
respx_mock.post(_BROWSER_PATH).mock(return_value=httpx.Response(status, json={"error": {"message": "err"}}))
|
|
90
|
+
with pytest.raises(error_class):
|
|
91
|
+
client.browser.sessions.create()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
95
|
+
# browser.sessions.create() — async
|
|
96
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
100
|
+
async def test_async_create_returns_browser_session(async_client: AsyncTinyFish, respx_mock: MockRouter):
|
|
101
|
+
respx_mock.post(_BROWSER_PATH).mock(return_value=httpx.Response(201, json=_session_response()))
|
|
102
|
+
async with async_client as c:
|
|
103
|
+
result = await c.browser.sessions.create()
|
|
104
|
+
assert isinstance(result, BrowserSession)
|
|
105
|
+
assert result.session_id == _SESSION_ID
|
|
106
|
+
assert result.cdp_url == _CDP_URL
|
|
107
|
+
assert result.base_url == _BASE_URL
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.parametrize("name,kwargs,expected", _PARAM_CASES, ids=[c[0] for c in _PARAM_CASES])
|
|
111
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
112
|
+
async def test_async_create_sends_params(
|
|
113
|
+
name: str, kwargs: dict, expected: dict, async_client: AsyncTinyFish, respx_mock: MockRouter
|
|
114
|
+
):
|
|
115
|
+
route = respx_mock.post(_BROWSER_PATH).mock(return_value=httpx.Response(201, json=_session_response()))
|
|
116
|
+
async with async_client as c:
|
|
117
|
+
await c.browser.sessions.create(**kwargs)
|
|
118
|
+
parsed = json.loads(route.calls[0].request.content)
|
|
119
|
+
for key, value in expected.items():
|
|
120
|
+
assert parsed[key] == value
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
124
|
+
async def test_async_create_omits_none_values(async_client: AsyncTinyFish, respx_mock: MockRouter):
|
|
125
|
+
route = respx_mock.post(_BROWSER_PATH).mock(return_value=httpx.Response(201, json=_session_response()))
|
|
126
|
+
async with async_client as c:
|
|
127
|
+
await c.browser.sessions.create()
|
|
128
|
+
parsed = json.loads(route.calls[0].request.content)
|
|
129
|
+
assert "url" not in parsed
|
|
130
|
+
assert "timeout_seconds" not in parsed
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.mark.parametrize("status,error_class", _ERROR_CASES, ids=[str(c[0]) for c in _ERROR_CASES])
|
|
134
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
135
|
+
async def test_async_create_error_status(
|
|
136
|
+
status: int, error_class: type, async_client: AsyncTinyFish, respx_mock: MockRouter
|
|
137
|
+
):
|
|
138
|
+
respx_mock.post(_BROWSER_PATH).mock(return_value=httpx.Response(status, json={"error": {"message": "err"}}))
|
|
139
|
+
async with async_client as c:
|
|
140
|
+
with pytest.raises(error_class):
|
|
141
|
+
await c.browser.sessions.create()
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Tests for fetch resource — get_contents()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
from respx import MockRouter
|
|
11
|
+
|
|
12
|
+
from tests.conftest import BASE_URL
|
|
13
|
+
from tinyfish import AsyncTinyFish, AuthenticationError, SDKError, TinyFish
|
|
14
|
+
from tinyfish.fetch.types import FetchResponse
|
|
15
|
+
|
|
16
|
+
# ─── Paths ────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
_FETCH_PATH = "/v1/fetch"
|
|
19
|
+
|
|
20
|
+
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _result(url: str = "https://example.com", **overrides: Any) -> dict[str, Any]:
|
|
24
|
+
base: dict[str, Any] = {
|
|
25
|
+
"url": url,
|
|
26
|
+
"final_url": f"{url}/",
|
|
27
|
+
"title": "Example",
|
|
28
|
+
"description": "An example page",
|
|
29
|
+
"language": "en",
|
|
30
|
+
"author": None,
|
|
31
|
+
"published_date": None,
|
|
32
|
+
"text": "# Example\nHello world",
|
|
33
|
+
"format": "markdown",
|
|
34
|
+
"links": [],
|
|
35
|
+
"image_links": [],
|
|
36
|
+
"latency_ms": 450,
|
|
37
|
+
}
|
|
38
|
+
return {**base, **overrides}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _response(
|
|
42
|
+
results: list[dict[str, Any]] | None = None,
|
|
43
|
+
errors: list[dict[str, Any]] | None = None,
|
|
44
|
+
) -> dict[str, Any]:
|
|
45
|
+
return {
|
|
46
|
+
"results": results or [],
|
|
47
|
+
"errors": errors or [],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
52
|
+
# fetch.get_contents() — sync
|
|
53
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
57
|
+
def test_get_contents_returns_response(client: TinyFish, respx_mock: MockRouter):
|
|
58
|
+
respx_mock.post(_FETCH_PATH).mock(return_value=httpx.Response(200, json=_response(results=[_result()])))
|
|
59
|
+
result = client.fetch.get_contents(["https://example.com"])
|
|
60
|
+
assert isinstance(result, FetchResponse)
|
|
61
|
+
assert len(result.results) == 1
|
|
62
|
+
assert result.results[0].url == "https://example.com"
|
|
63
|
+
assert result.results[0].title == "Example"
|
|
64
|
+
assert result.results[0].format == "markdown"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
68
|
+
def test_get_contents_sends_urls(client: TinyFish, respx_mock: MockRouter):
|
|
69
|
+
route = respx_mock.post(_FETCH_PATH).mock(return_value=httpx.Response(200, json=_response(results=[_result()])))
|
|
70
|
+
client.fetch.get_contents(["https://example.com"])
|
|
71
|
+
body = json.loads(route.calls[0].request.content)
|
|
72
|
+
assert body["urls"] == ["https://example.com"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
76
|
+
def test_get_contents_multiple_urls(client: TinyFish, respx_mock: MockRouter):
|
|
77
|
+
urls = ["https://a.com", "https://b.com", "https://c.com"]
|
|
78
|
+
route = respx_mock.post(_FETCH_PATH).mock(
|
|
79
|
+
return_value=httpx.Response(200, json=_response(results=[_result(u) for u in urls]))
|
|
80
|
+
)
|
|
81
|
+
result = client.fetch.get_contents(urls)
|
|
82
|
+
body = json.loads(route.calls[0].request.content)
|
|
83
|
+
assert body["urls"] == urls
|
|
84
|
+
assert len(result.results) == 3
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize(
|
|
88
|
+
("kwarg", "value", "body_key"),
|
|
89
|
+
[
|
|
90
|
+
("format", "html", "format"),
|
|
91
|
+
("links", True, "links"),
|
|
92
|
+
("image_links", True, "image_links"),
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
96
|
+
def test_get_contents_optional_param(
|
|
97
|
+
kwarg: str, value: object, body_key: str, client: TinyFish, respx_mock: MockRouter
|
|
98
|
+
):
|
|
99
|
+
route = respx_mock.post(_FETCH_PATH).mock(return_value=httpx.Response(200, json=_response(results=[_result()])))
|
|
100
|
+
client.fetch.get_contents(["https://example.com"], **{kwarg: value})
|
|
101
|
+
body = json.loads(route.calls[0].request.content)
|
|
102
|
+
assert body[body_key] == value
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
106
|
+
def test_get_contents_omits_optional_params(client: TinyFish, respx_mock: MockRouter):
|
|
107
|
+
route = respx_mock.post(_FETCH_PATH).mock(return_value=httpx.Response(200, json=_response(results=[_result()])))
|
|
108
|
+
client.fetch.get_contents(["https://example.com"])
|
|
109
|
+
body = json.loads(route.calls[0].request.content)
|
|
110
|
+
assert "format" not in body
|
|
111
|
+
assert "links" not in body
|
|
112
|
+
assert "image_links" not in body
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
116
|
+
def test_get_contents_all_params(client: TinyFish, respx_mock: MockRouter):
|
|
117
|
+
route = respx_mock.post(_FETCH_PATH).mock(return_value=httpx.Response(200, json=_response(results=[_result()])))
|
|
118
|
+
client.fetch.get_contents(["https://example.com"], format="json", links=True, image_links=True)
|
|
119
|
+
body = json.loads(route.calls[0].request.content)
|
|
120
|
+
assert body["format"] == "json"
|
|
121
|
+
assert body["links"] is True
|
|
122
|
+
assert body["image_links"] is True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
126
|
+
def test_get_contents_nullable_fields(client: TinyFish, respx_mock: MockRouter):
|
|
127
|
+
respx_mock.post(_FETCH_PATH).mock(
|
|
128
|
+
return_value=httpx.Response(
|
|
129
|
+
200,
|
|
130
|
+
json=_response(
|
|
131
|
+
results=[
|
|
132
|
+
_result(
|
|
133
|
+
final_url=None,
|
|
134
|
+
title=None,
|
|
135
|
+
description=None,
|
|
136
|
+
language=None,
|
|
137
|
+
author=None,
|
|
138
|
+
published_date=None,
|
|
139
|
+
text=None,
|
|
140
|
+
latency_ms=None,
|
|
141
|
+
)
|
|
142
|
+
]
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
r = client.fetch.get_contents(["https://example.com"]).results[0]
|
|
147
|
+
assert r.final_url is None
|
|
148
|
+
assert r.title is None
|
|
149
|
+
assert r.text is None
|
|
150
|
+
assert r.latency_ms is None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
154
|
+
def test_get_contents_with_errors(client: TinyFish, respx_mock: MockRouter):
|
|
155
|
+
respx_mock.post(_FETCH_PATH).mock(
|
|
156
|
+
return_value=httpx.Response(
|
|
157
|
+
200,
|
|
158
|
+
json=_response(
|
|
159
|
+
results=[_result()],
|
|
160
|
+
errors=[{"url": "https://bad.com", "error": "Connection timeout"}],
|
|
161
|
+
),
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
result = client.fetch.get_contents(["https://example.com", "https://bad.com"])
|
|
165
|
+
assert len(result.results) == 1
|
|
166
|
+
assert len(result.errors) == 1
|
|
167
|
+
assert result.errors[0].url == "https://bad.com"
|
|
168
|
+
assert result.errors[0].error == "Connection timeout"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@pytest.mark.parametrize(
|
|
172
|
+
("urls", "match"),
|
|
173
|
+
[
|
|
174
|
+
([], "non-empty"),
|
|
175
|
+
([f"https://x{i}.com" for i in range(11)], "at most 10"),
|
|
176
|
+
],
|
|
177
|
+
ids=["empty", "too_many"],
|
|
178
|
+
)
|
|
179
|
+
def test_get_contents_url_count_validation(urls: list[str], match: str, client: TinyFish):
|
|
180
|
+
with pytest.raises(SDKError, match=match):
|
|
181
|
+
client.fetch.get_contents(urls)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
185
|
+
def test_get_contents_exactly_10_urls(client: TinyFish, respx_mock: MockRouter):
|
|
186
|
+
urls = [f"https://example{i}.com" for i in range(10)]
|
|
187
|
+
respx_mock.post(_FETCH_PATH).mock(
|
|
188
|
+
return_value=httpx.Response(200, json=_response(results=[_result(u) for u in urls]))
|
|
189
|
+
)
|
|
190
|
+
assert len(client.fetch.get_contents(urls).results) == 10
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
194
|
+
def test_get_contents_401(client: TinyFish, respx_mock: MockRouter):
|
|
195
|
+
respx_mock.post(_FETCH_PATH).mock(return_value=httpx.Response(401, json={"error": {"message": "unauthorized"}}))
|
|
196
|
+
with pytest.raises(AuthenticationError):
|
|
197
|
+
client.fetch.get_contents(["https://example.com"])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
201
|
+
# fetch.get_contents() — async
|
|
202
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
206
|
+
async def test_async_get_contents_returns_response(async_client: AsyncTinyFish, respx_mock: MockRouter):
|
|
207
|
+
respx_mock.post(_FETCH_PATH).mock(return_value=httpx.Response(200, json=_response(results=[_result()])))
|
|
208
|
+
async with async_client as c:
|
|
209
|
+
result = await c.fetch.get_contents(["https://example.com"])
|
|
210
|
+
assert isinstance(result, FetchResponse)
|
|
211
|
+
assert len(result.results) == 1
|
|
212
|
+
assert result.results[0].url == "https://example.com"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@pytest.mark.parametrize(
|
|
216
|
+
("kwarg", "value", "body_key"),
|
|
217
|
+
[
|
|
218
|
+
("format", "html", "format"),
|
|
219
|
+
("links", True, "links"),
|
|
220
|
+
("image_links", True, "image_links"),
|
|
221
|
+
],
|
|
222
|
+
)
|
|
223
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
224
|
+
async def test_async_get_contents_optional_param(
|
|
225
|
+
kwarg: str, value: object, body_key: str, async_client: AsyncTinyFish, respx_mock: MockRouter
|
|
226
|
+
):
|
|
227
|
+
route = respx_mock.post(_FETCH_PATH).mock(return_value=httpx.Response(200, json=_response(results=[_result()])))
|
|
228
|
+
async with async_client as c:
|
|
229
|
+
await c.fetch.get_contents(["https://example.com"], **{kwarg: value})
|
|
230
|
+
body = json.loads(route.calls[0].request.content)
|
|
231
|
+
assert body[body_key] == value
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
235
|
+
async def test_async_get_contents_with_errors(async_client: AsyncTinyFish, respx_mock: MockRouter):
|
|
236
|
+
respx_mock.post(_FETCH_PATH).mock(
|
|
237
|
+
return_value=httpx.Response(
|
|
238
|
+
200,
|
|
239
|
+
json=_response(errors=[{"url": "https://bad.com", "error": "timeout"}]),
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
async with async_client as c:
|
|
243
|
+
result = await c.fetch.get_contents(["https://bad.com"])
|
|
244
|
+
assert len(result.errors) == 1
|
|
245
|
+
assert result.errors[0].error == "timeout"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@pytest.mark.parametrize(
|
|
249
|
+
("urls", "match"),
|
|
250
|
+
[
|
|
251
|
+
([], "non-empty"),
|
|
252
|
+
([f"https://x{i}.com" for i in range(11)], "at most 10"),
|
|
253
|
+
],
|
|
254
|
+
ids=["empty", "too_many"],
|
|
255
|
+
)
|
|
256
|
+
async def test_async_get_contents_url_count_validation(urls: list[str], match: str, async_client: AsyncTinyFish):
|
|
257
|
+
async with async_client as c:
|
|
258
|
+
with pytest.raises(SDKError, match=match):
|
|
259
|
+
await c.fetch.get_contents(urls)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
263
|
+
async def test_async_get_contents_401(async_client: AsyncTinyFish, respx_mock: MockRouter):
|
|
264
|
+
respx_mock.post(_FETCH_PATH).mock(return_value=httpx.Response(401, json={"error": {"message": "unauthorized"}}))
|
|
265
|
+
async with async_client as c:
|
|
266
|
+
with pytest.raises(AuthenticationError):
|
|
267
|
+
await c.fetch.get_contents(["https://example.com"])
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Tests for search resource — query()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
from respx import MockRouter
|
|
10
|
+
|
|
11
|
+
from tests.conftest import BASE_URL
|
|
12
|
+
from tinyfish import AsyncTinyFish, AuthenticationError, SearchQueryResponse, TinyFish
|
|
13
|
+
|
|
14
|
+
# ─── Constants ────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
_SEARCH_PATH = "/v1/search"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _search_response(
|
|
20
|
+
*,
|
|
21
|
+
query: str = "web automation tools",
|
|
22
|
+
total_results: int = 2,
|
|
23
|
+
results: list[dict[str, Any]] | None = None,
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
if results is None:
|
|
26
|
+
results = [
|
|
27
|
+
{
|
|
28
|
+
"position": 1,
|
|
29
|
+
"site_name": "example.com",
|
|
30
|
+
"snippet": "Example snippet one",
|
|
31
|
+
"title": "Example Title One",
|
|
32
|
+
"url": "https://example.com/one",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"position": 2,
|
|
36
|
+
"site_name": "other.com",
|
|
37
|
+
"snippet": "Example snippet two",
|
|
38
|
+
"title": "Example Title Two",
|
|
39
|
+
"url": "https://other.com/two",
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
return {"query": query, "results": results, "total_results": total_results}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ─── Sync/async call helpers ─────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _do_query(client: TinyFish | AsyncTinyFish, *args: Any, **kwargs: Any) -> SearchQueryResponse:
|
|
49
|
+
if isinstance(client, AsyncTinyFish):
|
|
50
|
+
async with client as c:
|
|
51
|
+
return await c.search.query(*args, **kwargs)
|
|
52
|
+
return client.search.query(*args, **kwargs)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
56
|
+
# search.query() — sync
|
|
57
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
61
|
+
def test_query_returns_response(client: TinyFish, respx_mock: MockRouter):
|
|
62
|
+
respx_mock.get(_SEARCH_PATH).mock(return_value=httpx.Response(200, json=_search_response()))
|
|
63
|
+
result = client.search.query("web automation tools")
|
|
64
|
+
assert isinstance(result, SearchQueryResponse)
|
|
65
|
+
assert result.query == "web automation tools"
|
|
66
|
+
assert result.total_results == 2
|
|
67
|
+
assert len(result.results) == 2
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
71
|
+
def test_query_parses_result_fields(client: TinyFish, respx_mock: MockRouter):
|
|
72
|
+
respx_mock.get(_SEARCH_PATH).mock(return_value=httpx.Response(200, json=_search_response()))
|
|
73
|
+
first = client.search.query("web automation tools").results[0]
|
|
74
|
+
assert first.position == 1
|
|
75
|
+
assert first.site_name == "example.com"
|
|
76
|
+
assert first.snippet == "Example snippet one"
|
|
77
|
+
assert first.title == "Example Title One"
|
|
78
|
+
assert first.url == "https://example.com/one"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.parametrize(
|
|
82
|
+
("kwargs", "expected_key", "expected_val"),
|
|
83
|
+
[
|
|
84
|
+
({"location": "United States"}, "location", "United States"),
|
|
85
|
+
({"language": "en"}, "language", "en"),
|
|
86
|
+
],
|
|
87
|
+
)
|
|
88
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
89
|
+
def test_query_forwards_optional_param(
|
|
90
|
+
kwargs: dict[str, str],
|
|
91
|
+
expected_key: str,
|
|
92
|
+
expected_val: str,
|
|
93
|
+
client: TinyFish,
|
|
94
|
+
respx_mock: MockRouter,
|
|
95
|
+
):
|
|
96
|
+
route = respx_mock.get(_SEARCH_PATH).mock(return_value=httpx.Response(200, json=_search_response()))
|
|
97
|
+
client.search.query("test", **kwargs)
|
|
98
|
+
assert route.calls[0].request.url.params[expected_key] == expected_val
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
102
|
+
def test_query_sends_all_params(client: TinyFish, respx_mock: MockRouter):
|
|
103
|
+
route = respx_mock.get(_SEARCH_PATH).mock(return_value=httpx.Response(200, json=_search_response()))
|
|
104
|
+
client.search.query("test", location="US", language="en")
|
|
105
|
+
params = route.calls[0].request.url.params
|
|
106
|
+
assert params["query"] == "test"
|
|
107
|
+
assert params["location"] == "US"
|
|
108
|
+
assert params["language"] == "en"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
112
|
+
def test_query_omits_optional_params(client: TinyFish, respx_mock: MockRouter):
|
|
113
|
+
route = respx_mock.get(_SEARCH_PATH).mock(return_value=httpx.Response(200, json=_search_response()))
|
|
114
|
+
client.search.query("test")
|
|
115
|
+
params = route.calls[0].request.url.params
|
|
116
|
+
assert "location" not in params
|
|
117
|
+
assert "language" not in params
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
121
|
+
def test_query_empty_results(client: TinyFish, respx_mock: MockRouter):
|
|
122
|
+
respx_mock.get(_SEARCH_PATH).mock(
|
|
123
|
+
return_value=httpx.Response(200, json=_search_response(results=[], total_results=0))
|
|
124
|
+
)
|
|
125
|
+
result = client.search.query("no results")
|
|
126
|
+
assert result.results == []
|
|
127
|
+
assert result.total_results == 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@pytest.mark.parametrize("bad_query", ["", " "])
|
|
131
|
+
def test_query_rejects_empty_query(bad_query: str, client: TinyFish):
|
|
132
|
+
with pytest.raises(ValueError, match="non-empty"):
|
|
133
|
+
client.search.query(bad_query)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
137
|
+
def test_query_401(client: TinyFish, respx_mock: MockRouter):
|
|
138
|
+
respx_mock.get(_SEARCH_PATH).mock(return_value=httpx.Response(401, json={"error": {"message": "unauthorized"}}))
|
|
139
|
+
with pytest.raises(AuthenticationError):
|
|
140
|
+
client.search.query("test")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
144
|
+
# search.query() — async (mirrors sync tests above)
|
|
145
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
149
|
+
async def test_async_query_returns_response(async_client: AsyncTinyFish, respx_mock: MockRouter):
|
|
150
|
+
respx_mock.get(_SEARCH_PATH).mock(return_value=httpx.Response(200, json=_search_response()))
|
|
151
|
+
result = await _do_query(async_client, "web automation tools")
|
|
152
|
+
assert isinstance(result, SearchQueryResponse)
|
|
153
|
+
assert result.query == "web automation tools"
|
|
154
|
+
assert result.total_results == 2
|
|
155
|
+
assert len(result.results) == 2
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
159
|
+
async def test_async_query_parses_result_fields(async_client: AsyncTinyFish, respx_mock: MockRouter):
|
|
160
|
+
respx_mock.get(_SEARCH_PATH).mock(return_value=httpx.Response(200, json=_search_response()))
|
|
161
|
+
result = await _do_query(async_client, "web automation tools")
|
|
162
|
+
first = result.results[0]
|
|
163
|
+
assert first.position == 1
|
|
164
|
+
assert first.site_name == "example.com"
|
|
165
|
+
assert first.snippet == "Example snippet one"
|
|
166
|
+
assert first.title == "Example Title One"
|
|
167
|
+
assert first.url == "https://example.com/one"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.mark.parametrize(
|
|
171
|
+
("kwargs", "expected_key", "expected_val"),
|
|
172
|
+
[
|
|
173
|
+
({"location": "United States"}, "location", "United States"),
|
|
174
|
+
({"language": "en"}, "language", "en"),
|
|
175
|
+
],
|
|
176
|
+
)
|
|
177
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
178
|
+
async def test_async_query_forwards_optional_param(
|
|
179
|
+
kwargs: dict[str, str],
|
|
180
|
+
expected_key: str,
|
|
181
|
+
expected_val: str,
|
|
182
|
+
async_client: AsyncTinyFish,
|
|
183
|
+
respx_mock: MockRouter,
|
|
184
|
+
):
|
|
185
|
+
route = respx_mock.get(_SEARCH_PATH).mock(return_value=httpx.Response(200, json=_search_response()))
|
|
186
|
+
await _do_query(async_client, "test", **kwargs)
|
|
187
|
+
assert route.calls[0].request.url.params[expected_key] == expected_val
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
191
|
+
async def test_async_query_sends_all_params(async_client: AsyncTinyFish, respx_mock: MockRouter):
|
|
192
|
+
route = respx_mock.get(_SEARCH_PATH).mock(return_value=httpx.Response(200, json=_search_response()))
|
|
193
|
+
await _do_query(async_client, "test", location="UK", language="en")
|
|
194
|
+
params = route.calls[0].request.url.params
|
|
195
|
+
assert params["query"] == "test"
|
|
196
|
+
assert params["location"] == "UK"
|
|
197
|
+
assert params["language"] == "en"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
201
|
+
async def test_async_query_omits_optional_params(async_client: AsyncTinyFish, respx_mock: MockRouter):
|
|
202
|
+
route = respx_mock.get(_SEARCH_PATH).mock(return_value=httpx.Response(200, json=_search_response()))
|
|
203
|
+
await _do_query(async_client, "test")
|
|
204
|
+
params = route.calls[0].request.url.params
|
|
205
|
+
assert "location" not in params
|
|
206
|
+
assert "language" not in params
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
210
|
+
async def test_async_query_empty_results(async_client: AsyncTinyFish, respx_mock: MockRouter):
|
|
211
|
+
respx_mock.get(_SEARCH_PATH).mock(
|
|
212
|
+
return_value=httpx.Response(200, json=_search_response(results=[], total_results=0))
|
|
213
|
+
)
|
|
214
|
+
result = await _do_query(async_client, "nothing")
|
|
215
|
+
assert result.results == []
|
|
216
|
+
assert result.total_results == 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@pytest.mark.parametrize("bad_query", ["", " "])
|
|
220
|
+
async def test_async_query_rejects_empty_query(bad_query: str, async_client: AsyncTinyFish):
|
|
221
|
+
with pytest.raises(ValueError, match="non-empty"):
|
|
222
|
+
await _do_query(async_client, bad_query)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@pytest.mark.respx(base_url=BASE_URL)
|
|
226
|
+
async def test_async_query_401(async_client: AsyncTinyFish, respx_mock: MockRouter):
|
|
227
|
+
respx_mock.get(_SEARCH_PATH).mock(return_value=httpx.Response(401, json={"error": {"message": "unauthorized"}}))
|
|
228
|
+
with pytest.raises(AuthenticationError):
|
|
229
|
+
await _do_query(async_client, "test")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|