tinyfish 0.2.2__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,104 @@
1
+ """
2
+ TinyFish SDK - State-of-the-Art web agents in an API
3
+ """
4
+
5
+ from importlib.metadata import version
6
+
7
+ # Clients
8
+ # Exceptions
9
+ from ._utils.exceptions import (
10
+ APIConnectionError,
11
+ APIError,
12
+ APIStatusError,
13
+ APITimeoutError,
14
+ AuthenticationError,
15
+ BadRequestError,
16
+ ConflictError,
17
+ InternalServerError,
18
+ NotFoundError,
19
+ PermissionDeniedError,
20
+ RateLimitError,
21
+ RequestTimeoutError,
22
+ SDKError,
23
+ SSEParseError,
24
+ UnprocessableEntityError,
25
+ )
26
+
27
+ # Agent resource
28
+ from .agent import AgentStream, AsyncAgentStream
29
+
30
+ # Agent types
31
+ from .agent.types import (
32
+ AgentRunAsyncResponse,
33
+ AgentRunResponse,
34
+ AgentRunWithStreamingResponse,
35
+ BrowserProfile,
36
+ CompleteEvent,
37
+ EventType,
38
+ HeartbeatEvent,
39
+ ProgressEvent,
40
+ ProxyConfig,
41
+ ProxyCountryCode,
42
+ StartedEvent,
43
+ StreamingUrlEvent,
44
+ )
45
+ from .client import AsyncTinyFish, TinyFish
46
+
47
+ # Runs types
48
+ from .runs.types import (
49
+ ErrorCategory,
50
+ PaginationInfo,
51
+ Run,
52
+ RunError,
53
+ RunListResponse,
54
+ RunStatus,
55
+ SortDirection,
56
+ )
57
+
58
+ __version__ = version("tinyfish")
59
+
60
+ __all__ = [
61
+ # Clients
62
+ "TinyFish",
63
+ "AsyncTinyFish",
64
+ # Exceptions
65
+ "SDKError",
66
+ "SSEParseError",
67
+ "APIError",
68
+ "APIConnectionError",
69
+ "APITimeoutError",
70
+ "APIStatusError",
71
+ "BadRequestError",
72
+ "AuthenticationError",
73
+ "PermissionDeniedError",
74
+ "NotFoundError",
75
+ "RequestTimeoutError",
76
+ "ConflictError",
77
+ "UnprocessableEntityError",
78
+ "RateLimitError",
79
+ "InternalServerError",
80
+ # Agent resource
81
+ "AgentStream",
82
+ "AsyncAgentStream",
83
+ # Agent types
84
+ "EventType",
85
+ "BrowserProfile",
86
+ "ProxyCountryCode",
87
+ "ProxyConfig",
88
+ "AgentRunResponse",
89
+ "AgentRunAsyncResponse",
90
+ "StartedEvent",
91
+ "StreamingUrlEvent",
92
+ "ProgressEvent",
93
+ "HeartbeatEvent",
94
+ "CompleteEvent",
95
+ "AgentRunWithStreamingResponse",
96
+ # Runs types
97
+ "ErrorCategory",
98
+ "SortDirection",
99
+ "RunError",
100
+ "Run",
101
+ "RunStatus",
102
+ "RunListResponse",
103
+ "PaginationInfo",
104
+ ]
@@ -0,0 +1,26 @@
1
+ """Reusable SDK foundation.
2
+
3
+ Provides base classes for building HTTP API SDKs:
4
+ - Clients: HTTP client with auth, retries, error handling
5
+ - Resources: Containers for grouping related API methods
6
+ - Exceptions: Complete error hierarchy
7
+ """
8
+
9
+ from .client import BaseAsyncAPIClient as BaseAsyncAPIClient
10
+ from .client import BaseSyncAPIClient as BaseSyncAPIClient
11
+ from .exceptions import APIConnectionError as APIConnectionError
12
+ from .exceptions import APIError as APIError
13
+ from .exceptions import APIStatusError as APIStatusError
14
+ from .exceptions import APITimeoutError as APITimeoutError
15
+ from .exceptions import AuthenticationError as AuthenticationError
16
+ from .exceptions import BadRequestError as BadRequestError
17
+ from .exceptions import ConflictError as ConflictError
18
+ from .exceptions import InternalServerError as InternalServerError
19
+ from .exceptions import NotFoundError as NotFoundError
20
+ from .exceptions import PermissionDeniedError as PermissionDeniedError
21
+ from .exceptions import RateLimitError as RateLimitError
22
+ from .exceptions import RequestTimeoutError as RequestTimeoutError
23
+ from .exceptions import SDKError as SDKError
24
+ from .exceptions import UnprocessableEntityError as UnprocessableEntityError
25
+ from .resource import BaseAsyncAPIResource as BaseAsyncAPIResource
26
+ from .resource import BaseSyncAPIResource as BaseSyncAPIResource
@@ -0,0 +1,4 @@
1
+ """HTTP clients with auth, retries, and error handling."""
2
+
3
+ from .async_ import BaseAsyncAPIClient as BaseAsyncAPIClient
4
+ from .sync import BaseSyncAPIClient as BaseSyncAPIClient
@@ -0,0 +1,137 @@
1
+ """Shared base client internals."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from importlib.metadata import version as _pkg_version
7
+ from typing import TypeVar
8
+
9
+ import httpx
10
+ from pydantic import BaseModel
11
+
12
+ from ..exceptions import (
13
+ APIStatusError,
14
+ AuthenticationError,
15
+ BadRequestError,
16
+ ConflictError,
17
+ InternalServerError,
18
+ NotFoundError,
19
+ PermissionDeniedError,
20
+ RateLimitError,
21
+ RequestTimeoutError,
22
+ UnprocessableEntityError,
23
+ )
24
+
25
+ ResponseT = TypeVar("ResponseT", bound=BaseModel)
26
+
27
+ # 600 s = 10 min — matches the platform's own run timeout
28
+ # (frontend/app/v1/lib/one-off-run.ts: RUN_TIMEOUT_MS = 60_000 * 10)
29
+ _DEFAULT_TIMEOUT: float = 600.0
30
+ _DEFAULT_MAX_RETRIES: int = 2
31
+ _RETRY_MULTIPLIER: float = 0.5
32
+ _RETRY_MAX_WAIT: float = 8.0
33
+
34
+
35
+ class _BaseClient:
36
+ """Shared logic for sync and async clients. Not for direct use."""
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ api_key: str | None,
42
+ base_url: str,
43
+ max_retries: int = _DEFAULT_MAX_RETRIES,
44
+ ) -> None:
45
+ """
46
+ Args:
47
+ api_key: API key for authentication. If None, falls back to the
48
+ TINYFISH_API_KEY environment variable.
49
+ base_url: Base URL for all API requests
50
+ max_retries: Default max retry attempts (default: 2)
51
+ """
52
+ # If no key was passed explicitly, try the environment variable.
53
+ # This lets users do `export TINYFISH_API_KEY=xxx` and omit api_key
54
+ # entirely when constructing the client.
55
+ resolved_key = api_key or os.environ.get("TINYFISH_API_KEY")
56
+ if not resolved_key:
57
+ raise ValueError(
58
+ "No API key provided. Pass api_key= to the client or set the TINYFISH_API_KEY environment variable."
59
+ )
60
+
61
+ # Stored with leading underscores so they are not part of the public API
62
+ # and do not appear in autocomplete or when someone inspects the object
63
+ # casually (e.g. vars(client) in a debug session).
64
+ self._api_key = resolved_key
65
+ self._base_url = base_url
66
+ self._max_retries = max_retries
67
+
68
+ def __repr__(self) -> str:
69
+ # Mask the key so that logging `repr(client)` never writes a live secret
70
+ # to stdout, log files, or error tracking services. We keep the first 8
71
+ # characters so the user can still tell *which* key is in use when they
72
+ # have more than one (e.g. separate dev/prod keys).
73
+ masked = f"{self._api_key[:8]}****" if len(self._api_key) > 8 else "****"
74
+ return f"{self.__class__.__name__}(base_url={self._base_url!r}, api_key={masked!r})"
75
+
76
+ def _build_default_headers(self) -> dict[str, str]:
77
+ return {
78
+ "Content-Type": "application/json",
79
+ "Accept": "application/json",
80
+ "X-API-Key": self._api_key,
81
+ "User-Agent": f"tinyfish-python/{_pkg_version('tinyfish')}",
82
+ }
83
+
84
+ def _make_status_error(self, response: httpx.Response) -> APIStatusError:
85
+ """
86
+ Translate an error HTTP response into the appropriate SDK exception.
87
+
88
+ Called by _request() in sync.py and async_.py immediately after a response
89
+ comes back with response.is_error == True. httpx does not raise on bad status
90
+ codes by itself — this method bridges that gap.
91
+
92
+ See docs/exceptions-and-errors-guide.md for the full error-handling architecture.
93
+ """
94
+ error_map: dict[int, type[APIStatusError]] = {
95
+ int(httpx.codes.BAD_REQUEST): BadRequestError,
96
+ int(httpx.codes.UNAUTHORIZED): AuthenticationError,
97
+ int(httpx.codes.FORBIDDEN): PermissionDeniedError,
98
+ int(httpx.codes.NOT_FOUND): NotFoundError,
99
+ int(httpx.codes.REQUEST_TIMEOUT): RequestTimeoutError,
100
+ int(httpx.codes.CONFLICT): ConflictError,
101
+ int(httpx.codes.UNPROCESSABLE_ENTITY): UnprocessableEntityError,
102
+ int(httpx.codes.TOO_MANY_REQUESTS): RateLimitError,
103
+ }
104
+
105
+ if response.is_server_error:
106
+ error_class = InternalServerError
107
+ else:
108
+ error_class = error_map.get(response.status_code, APIStatusError)
109
+
110
+ # Try to parse error message from JSON response
111
+ try:
112
+ error_data = response.json()
113
+ message = error_data.get("error", {}).get("message", response.text)
114
+ except Exception:
115
+ message = response.text or f"HTTP {response.status_code}"
116
+
117
+ if error_class is APIStatusError:
118
+ # Fallback: no concrete subclass matched, so pass the raw status code.
119
+ return error_class(message=message, response=response, status_code=response.status_code)
120
+ return error_class(message=message, response=response)
121
+
122
+ def _parse_response(
123
+ self,
124
+ response: httpx.Response,
125
+ cast_to: type[ResponseT],
126
+ ) -> ResponseT:
127
+ """
128
+ Parse HTTP response JSON into the given Pydantic model.
129
+
130
+ Args:
131
+ response: The HTTP response
132
+ cast_to: Pydantic model to validate and parse into
133
+
134
+ Raises:
135
+ pydantic.ValidationError: Response doesn't match the expected schema
136
+ """
137
+ return cast_to.model_validate(response.json())
@@ -0,0 +1,192 @@
1
+ """Base asynchronous API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from typing import Any, Self, TypeVar
7
+
8
+ import httpx
9
+ from pydantic import BaseModel
10
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
11
+
12
+ from ..exceptions import (
13
+ APIConnectionError,
14
+ APITimeoutError,
15
+ InternalServerError,
16
+ RateLimitError,
17
+ RequestTimeoutError,
18
+ )
19
+ from ._base import _DEFAULT_MAX_RETRIES, _DEFAULT_TIMEOUT, _RETRY_MAX_WAIT, _RETRY_MULTIPLIER, _BaseClient
20
+
21
+ ResponseT = TypeVar("ResponseT", bound=BaseModel)
22
+
23
+
24
+ class BaseAsyncAPIClient(_BaseClient):
25
+ """Base asynchronous API client — same interface as BaseSyncAPIClient with async/await."""
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ api_key: str,
31
+ base_url: str,
32
+ timeout: float = _DEFAULT_TIMEOUT,
33
+ max_retries: int = _DEFAULT_MAX_RETRIES,
34
+ ) -> None:
35
+ """
36
+ Args:
37
+ api_key: API key for authentication
38
+ base_url: Base URL for all API requests
39
+ timeout: Default timeout in seconds (default: 600.0)
40
+ max_retries: Default max retry attempts (default: 2)
41
+ """
42
+ super().__init__(api_key=api_key, base_url=base_url, max_retries=max_retries)
43
+
44
+ self._client = httpx.AsyncClient(
45
+ base_url=base_url,
46
+ timeout=timeout,
47
+ headers=self._build_default_headers(),
48
+ )
49
+
50
+ async def _request(
51
+ self,
52
+ method: str,
53
+ path: str,
54
+ *,
55
+ json: dict[str, Any] | None = None,
56
+ params: dict[str, Any] | None = None,
57
+ ) -> httpx.Response:
58
+ """
59
+ Make async HTTP request with automatic retries.
60
+
61
+ Retryable errors: 408, 429, 5xx, and network errors.
62
+ Non-retryable errors (400, 401, 403, 404, …) propagate immediately.
63
+
64
+ Args:
65
+ method: HTTP method (GET, POST, etc.)
66
+ path: URL path (relative to base_url)
67
+ json: JSON request body (optional)
68
+ params: Query parameters (optional)
69
+
70
+ Raises:
71
+ APITimeoutError: Request timed out
72
+ APIConnectionError: Network/connection error
73
+ APIStatusError: HTTP error status (4xx, 5xx)
74
+ """
75
+ max_attempts = self._max_retries + 1
76
+
77
+ # Retryable: 408, 429, 5xx, and network errors (TimeoutException ⊂ RequestError).
78
+ # Non-retryable status errors (400, 401, 403, 404, …) propagate immediately.
79
+ # After retries exhausted: httpx errors are wrapped into SDK types by the outer try/except.
80
+ @retry(
81
+ stop=stop_after_attempt(max_attempts),
82
+ wait=wait_exponential(multiplier=_RETRY_MULTIPLIER, max=_RETRY_MAX_WAIT),
83
+ retry=retry_if_exception_type(
84
+ (
85
+ RequestTimeoutError,
86
+ RateLimitError,
87
+ InternalServerError,
88
+ httpx.RequestError, # covers TimeoutException and connection errors
89
+ )
90
+ ),
91
+ reraise=True,
92
+ )
93
+ async def _execute() -> httpx.Response:
94
+ response = await self._client.request(
95
+ method=method,
96
+ url=path,
97
+ json=json,
98
+ params=params,
99
+ )
100
+ if response.is_error:
101
+ raise self._make_status_error(response)
102
+ return response
103
+
104
+ try:
105
+ return await _execute()
106
+ except httpx.TimeoutException as e:
107
+ raise APITimeoutError(str(e), request=e.request) from e
108
+ except httpx.RequestError as e:
109
+ raise APIConnectionError(str(e), request=e.request) from e
110
+
111
+ async def _get(
112
+ self,
113
+ path: str,
114
+ *,
115
+ cast_to: type[ResponseT],
116
+ params: dict[str, Any] | None = None,
117
+ ) -> ResponseT:
118
+ """Make GET request and parse the response into cast_to."""
119
+ response = await self._request("GET", path, params=params)
120
+ return self._parse_response(response, cast_to)
121
+
122
+ async def _post(
123
+ self,
124
+ path: str,
125
+ *,
126
+ json: dict[str, Any] | None = None,
127
+ cast_to: type[ResponseT],
128
+ ) -> ResponseT:
129
+ """Make POST request and parse the response into cast_to."""
130
+ response = await self._request("POST", path, json=json)
131
+ return self._parse_response(response, cast_to)
132
+
133
+ async def _post_stream(
134
+ self,
135
+ path: str,
136
+ *,
137
+ json: dict[str, Any] | None = None,
138
+ ) -> AsyncIterator[str]:
139
+ """POST request that streams raw response lines (for SSE). Does not parse JSON.
140
+
141
+ Retries the initial connection on 429/5xx with exponential backoff,
142
+ matching the retry behaviour of ``_request()``. Once a 200 response
143
+ is received and streaming begins, no further retries are attempted.
144
+ """
145
+ max_attempts = self._max_retries + 1
146
+
147
+ @retry(
148
+ stop=stop_after_attempt(max_attempts),
149
+ wait=wait_exponential(multiplier=_RETRY_MULTIPLIER, max=_RETRY_MAX_WAIT),
150
+ retry=retry_if_exception_type(
151
+ (
152
+ RequestTimeoutError,
153
+ RateLimitError,
154
+ InternalServerError,
155
+ httpx.RequestError,
156
+ )
157
+ ),
158
+ reraise=True,
159
+ )
160
+ async def _connect() -> httpx.Response:
161
+ response = await self._client.send(
162
+ self._client.build_request("POST", path, json=json),
163
+ stream=True,
164
+ )
165
+ if response.is_error:
166
+ await response.aread()
167
+ await response.aclose()
168
+ raise self._make_status_error(response)
169
+ return response
170
+
171
+ try:
172
+ response = await _connect()
173
+ except httpx.TimeoutException as e:
174
+ raise APITimeoutError(str(e), request=e.request) from e
175
+ except httpx.RequestError as e:
176
+ raise APIConnectionError(str(e), request=e.request) from e
177
+
178
+ try:
179
+ async for line in response.aiter_lines():
180
+ yield line
181
+ finally:
182
+ await response.aclose()
183
+
184
+ async def close(self) -> None:
185
+ """Close the underlying HTTP client."""
186
+ await self._client.aclose()
187
+
188
+ async def __aenter__(self) -> Self:
189
+ return self
190
+
191
+ async def __aexit__(self, *args: object) -> None:
192
+ await self.close()
@@ -0,0 +1,191 @@
1
+ """Base synchronous API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from typing import Any, Self, TypeVar
7
+
8
+ import httpx
9
+ from pydantic import BaseModel
10
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
11
+
12
+ from ..exceptions import (
13
+ APIConnectionError,
14
+ APITimeoutError,
15
+ InternalServerError,
16
+ RateLimitError,
17
+ RequestTimeoutError,
18
+ )
19
+ from ._base import _DEFAULT_MAX_RETRIES, _DEFAULT_TIMEOUT, _RETRY_MAX_WAIT, _RETRY_MULTIPLIER, _BaseClient
20
+
21
+ ResponseT = TypeVar("ResponseT", bound=BaseModel)
22
+
23
+
24
+ class BaseSyncAPIClient(_BaseClient):
25
+ """Base synchronous API client with retries, error handling, and Pydantic response parsing."""
26
+
27
+ def __init__(
28
+ self,
29
+ *,
30
+ api_key: str,
31
+ base_url: str,
32
+ timeout: float = _DEFAULT_TIMEOUT,
33
+ max_retries: int = _DEFAULT_MAX_RETRIES,
34
+ ) -> None:
35
+ """
36
+ Args:
37
+ api_key: API key for authentication
38
+ base_url: Base URL for all API requests
39
+ timeout: Default timeout in seconds (default: 600.0)
40
+ max_retries: Default max retry attempts (default: 2)
41
+ """
42
+ super().__init__(api_key=api_key, base_url=base_url, max_retries=max_retries)
43
+
44
+ self._client = httpx.Client(
45
+ base_url=base_url,
46
+ timeout=timeout,
47
+ headers=self._build_default_headers(),
48
+ )
49
+
50
+ def _request(
51
+ self,
52
+ method: str,
53
+ path: str,
54
+ *,
55
+ json: dict[str, Any] | None = None,
56
+ params: dict[str, Any] | None = None,
57
+ ) -> httpx.Response:
58
+ """
59
+ Make HTTP request with automatic retries.
60
+
61
+ Retryable errors: 408, 429, 5xx, and network errors.
62
+ Non-retryable errors (400, 401, 403, 404, …) propagate immediately.
63
+
64
+ Args:
65
+ method: HTTP method (GET, POST, etc.)
66
+ path: URL path (relative to base_url)
67
+ json: JSON request body (optional)
68
+ params: Query parameters (optional)
69
+
70
+ Raises:
71
+ APITimeoutError: Request timed out
72
+ APIConnectionError: Network/connection error
73
+ APIStatusError: HTTP error status (4xx, 5xx)
74
+ """
75
+ max_attempts = self._max_retries + 1
76
+
77
+ # Retryable: 408, 429, 5xx, and network errors (TimeoutException ⊂ RequestError).
78
+ # Non-retryable status errors (400, 401, 403, 404, …) propagate immediately.
79
+ # After retries exhausted: httpx errors are wrapped into SDK types by the outer try/except.
80
+ @retry(
81
+ stop=stop_after_attempt(max_attempts),
82
+ wait=wait_exponential(multiplier=_RETRY_MULTIPLIER, max=_RETRY_MAX_WAIT),
83
+ retry=retry_if_exception_type(
84
+ (
85
+ RequestTimeoutError,
86
+ RateLimitError,
87
+ InternalServerError,
88
+ httpx.RequestError, # covers TimeoutException and connection errors
89
+ )
90
+ ),
91
+ reraise=True,
92
+ )
93
+ def _execute() -> httpx.Response:
94
+ response = self._client.request(
95
+ method=method,
96
+ url=path,
97
+ json=json,
98
+ params=params,
99
+ )
100
+ if response.is_error:
101
+ raise self._make_status_error(response)
102
+ return response
103
+
104
+ try:
105
+ return _execute()
106
+ except httpx.TimeoutException as e:
107
+ raise APITimeoutError(str(e), request=e.request) from e
108
+ except httpx.RequestError as e:
109
+ raise APIConnectionError(str(e), request=e.request) from e
110
+
111
+ def _get(
112
+ self,
113
+ path: str,
114
+ *,
115
+ cast_to: type[ResponseT],
116
+ params: dict[str, Any] | None = None,
117
+ ) -> ResponseT:
118
+ """Make GET request and parse the response into cast_to."""
119
+ response = self._request("GET", path, params=params)
120
+ return self._parse_response(response, cast_to)
121
+
122
+ def _post(
123
+ self,
124
+ path: str,
125
+ *,
126
+ json: dict[str, Any] | None = None,
127
+ cast_to: type[ResponseT],
128
+ ) -> ResponseT:
129
+ """Make POST request and parse the response into cast_to."""
130
+ response = self._request("POST", path, json=json)
131
+ return self._parse_response(response, cast_to)
132
+
133
+ def _post_stream(
134
+ self,
135
+ path: str,
136
+ *,
137
+ json: dict[str, Any] | None = None,
138
+ ) -> Iterator[str]:
139
+ """POST request that streams raw response lines (for SSE). Does not parse JSON.
140
+
141
+ Retries the initial connection on 429/5xx with exponential backoff,
142
+ matching the retry behaviour of ``_request()``. Once a 200 response
143
+ is received and streaming begins, no further retries are attempted.
144
+ """
145
+ max_attempts = self._max_retries + 1
146
+
147
+ @retry(
148
+ stop=stop_after_attempt(max_attempts),
149
+ wait=wait_exponential(multiplier=_RETRY_MULTIPLIER, max=_RETRY_MAX_WAIT),
150
+ retry=retry_if_exception_type(
151
+ (
152
+ RequestTimeoutError,
153
+ RateLimitError,
154
+ InternalServerError,
155
+ httpx.RequestError,
156
+ )
157
+ ),
158
+ reraise=True,
159
+ )
160
+ def _connect() -> httpx.Response:
161
+ response = self._client.send(
162
+ self._client.build_request("POST", path, json=json),
163
+ stream=True,
164
+ )
165
+ if response.is_error:
166
+ response.read()
167
+ response.close()
168
+ raise self._make_status_error(response)
169
+ return response
170
+
171
+ try:
172
+ response = _connect()
173
+ except httpx.TimeoutException as e:
174
+ raise APITimeoutError(str(e), request=e.request) from e
175
+ except httpx.RequestError as e:
176
+ raise APIConnectionError(str(e), request=e.request) from e
177
+
178
+ try:
179
+ yield from response.iter_lines()
180
+ finally:
181
+ response.close()
182
+
183
+ def close(self) -> None:
184
+ """Close the underlying HTTP client."""
185
+ self._client.close()
186
+
187
+ def __enter__(self) -> Self:
188
+ return self
189
+
190
+ def __exit__(self, *args: object) -> None:
191
+ self.close()