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.
Files changed (47) hide show
  1. tinyfish-0.2.5/AGENTS.md +1 -0
  2. {tinyfish-0.2.4 → tinyfish-0.2.5}/PKG-INFO +1 -1
  3. {tinyfish-0.2.4 → tinyfish-0.2.5}/RELEASE.md +5 -5
  4. {tinyfish-0.2.4 → tinyfish-0.2.5}/pyproject.toml +1 -1
  5. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/__init__.py +23 -2
  6. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/agent/types.py +1 -1
  7. tinyfish-0.2.5/src/tinyfish/browser/__init__.py +86 -0
  8. tinyfish-0.2.5/src/tinyfish/browser/types.py +24 -0
  9. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/client.py +15 -0
  10. tinyfish-0.2.5/src/tinyfish/fetch/__init__.py +99 -0
  11. tinyfish-0.2.5/src/tinyfish/fetch/types.py +40 -0
  12. tinyfish-0.2.5/src/tinyfish/search/__init__.py +79 -0
  13. tinyfish-0.2.5/src/tinyfish/search/types.py +21 -0
  14. {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/test_agent.py +3 -3
  15. tinyfish-0.2.5/tests/test_browser.py +141 -0
  16. tinyfish-0.2.5/tests/test_fetch.py +267 -0
  17. tinyfish-0.2.5/tests/test_search.py +229 -0
  18. {tinyfish-0.2.4 → tinyfish-0.2.5}/.gitignore +0 -0
  19. {tinyfish-0.2.4 → tinyfish-0.2.5}/.pre-commit-config.yaml +0 -0
  20. {tinyfish-0.2.4 → tinyfish-0.2.5}/CLAUDE.md +0 -0
  21. {tinyfish-0.2.4 → tinyfish-0.2.5}/README.md +0 -0
  22. {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/exceptions-and-errors-guide.md +0 -0
  23. {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/internal/api-integration-header.md +0 -0
  24. {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/internal/exceptions-and-errors-guide.md +0 -0
  25. {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/internal/publishing-private-guide.md +0 -0
  26. {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/pagination-guide.md +0 -0
  27. {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/proxy-and-browser-profiles.md +0 -0
  28. {tinyfish-0.2.4 → tinyfish-0.2.5}/docs/streaming-guide.md +0 -0
  29. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/__init__.py +0 -0
  30. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/client/__init__.py +0 -0
  31. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/client/_base.py +0 -0
  32. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/client/async_.py +0 -0
  33. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/client/sync.py +0 -0
  34. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/exceptions.py +0 -0
  35. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/resource.py +0 -0
  36. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/_utils/sse_parser.py +0 -0
  37. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/agent/__init__.py +0 -0
  38. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/py.typed +0 -0
  39. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/runs/__init__.py +0 -0
  40. {tinyfish-0.2.4 → tinyfish-0.2.5}/src/tinyfish/runs/types.py +0 -0
  41. {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/__init__.py +0 -0
  42. {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/conftest.py +0 -0
  43. {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/test_client.py +0 -0
  44. {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/test_errors.py +0 -0
  45. {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/test_runs.py +0 -0
  46. {tinyfish-0.2.4 → tinyfish-0.2.5}/tests/testing_guide.md +0 -0
  47. {tinyfish-0.2.4 → tinyfish-0.2.5}/uv.lock +0 -0
@@ -0,0 +1 @@
1
+ CLAUDE.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinyfish
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Official Python SDK for the TinyFish API
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27.0
@@ -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 tag matches the version in `pyproject.toml` — if they don't match, the build fails before anything is published.
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 Tinyfish; print('ok')"
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tinyfish"
3
- version = "0.2.4"
3
+ version = "0.2.5"
4
4
  description = "Official Python SDK for the TinyFish API"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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="resultJson", description="Structured JSON result extracted from the automation"
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
- "resultJson": {"price": "$999"},
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
- "resultJson": None,
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
- "resultJson": None,
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