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.
@@ -0,0 +1,159 @@
1
+ """Exception hierarchy for SDK errors.
2
+
3
+ All exceptions inherit from SDKError for easy catching.
4
+
5
+ Hierarchy:
6
+ SDKError
7
+ ├─ SSEParseError
8
+ └─ APIError
9
+ ├─ APIConnectionError
10
+ │ └─ APITimeoutError
11
+ └─ APIStatusError
12
+ ├─ BadRequestError (400)
13
+ ├─ AuthenticationError (401)
14
+ ├─ PermissionDeniedError (403)
15
+ ├─ NotFoundError (404)
16
+ ├─ RequestTimeoutError (408)
17
+ ├─ ConflictError (409)
18
+ ├─ UnprocessableEntityError (422)
19
+ ├─ RateLimitError (429)
20
+ └─ InternalServerError (500+)
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import httpx
26
+
27
+
28
+ class SDKError(Exception):
29
+ """Base exception for all SDK errors."""
30
+
31
+ pass
32
+
33
+
34
+ class SSEParseError(SDKError):
35
+ """Raised when a malformed SSE event cannot be parsed.
36
+
37
+ Attributes:
38
+ line: The raw SSE data line that failed to parse.
39
+ """
40
+
41
+ def __init__(self, message: str, *, line: str) -> None:
42
+ super().__init__(message)
43
+ self.line = line
44
+
45
+ def __repr__(self) -> str:
46
+ return f"SSEParseError(message={str(self)!r}, line={self.line!r})"
47
+
48
+
49
+ class APIError(SDKError):
50
+ """Base exception for all API-related errors."""
51
+
52
+ def __init__(
53
+ self,
54
+ message: str,
55
+ *,
56
+ request: httpx.Request | None = None,
57
+ response: httpx.Response | None = None,
58
+ ) -> None:
59
+ super().__init__(message)
60
+ self.message = message
61
+ self.request = request
62
+ self.response = response
63
+
64
+
65
+ class APIConnectionError(APIError):
66
+ """Network or connection failure."""
67
+
68
+ pass
69
+
70
+
71
+ class APITimeoutError(APIConnectionError):
72
+ """Request timeout."""
73
+
74
+ pass
75
+
76
+
77
+ class APIStatusError(APIError):
78
+ """API returned error status code (4xx or 5xx)."""
79
+
80
+ def __init__(
81
+ self,
82
+ message: str,
83
+ *,
84
+ response: httpx.Response,
85
+ status_code: int,
86
+ request: httpx.Request | None = None,
87
+ ) -> None:
88
+ super().__init__(message, request=request, response=response)
89
+ self.status_code = status_code
90
+
91
+ def __repr__(self) -> str:
92
+ return f"{self.__class__.__name__}(status_code={self.status_code}, message={self.message!r})"
93
+
94
+
95
+ class BadRequestError(APIStatusError):
96
+ """400 Bad Request."""
97
+
98
+ def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
99
+ super().__init__(message, response=response, status_code=int(httpx.codes.BAD_REQUEST), request=request)
100
+
101
+
102
+ class AuthenticationError(APIStatusError):
103
+ """401 Unauthorized."""
104
+
105
+ def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
106
+ super().__init__(message, response=response, status_code=int(httpx.codes.UNAUTHORIZED), request=request)
107
+
108
+
109
+ class PermissionDeniedError(APIStatusError):
110
+ """403 Forbidden."""
111
+
112
+ def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
113
+ super().__init__(message, response=response, status_code=int(httpx.codes.FORBIDDEN), request=request)
114
+
115
+
116
+ class NotFoundError(APIStatusError):
117
+ """404 Not Found."""
118
+
119
+ def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
120
+ super().__init__(message, response=response, status_code=int(httpx.codes.NOT_FOUND), request=request)
121
+
122
+
123
+ class RequestTimeoutError(APIStatusError):
124
+ """408 Request Timeout."""
125
+
126
+ def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
127
+ super().__init__(message, response=response, status_code=int(httpx.codes.REQUEST_TIMEOUT), request=request)
128
+
129
+
130
+ class ConflictError(APIStatusError):
131
+ """409 Conflict."""
132
+
133
+ def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
134
+ super().__init__(message, response=response, status_code=int(httpx.codes.CONFLICT), request=request)
135
+
136
+
137
+ class UnprocessableEntityError(APIStatusError):
138
+ """422 Unprocessable Entity."""
139
+
140
+ def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
141
+ super().__init__(message, response=response, status_code=int(httpx.codes.UNPROCESSABLE_ENTITY), request=request)
142
+
143
+
144
+ class RateLimitError(APIStatusError):
145
+ """429 Too Many Requests."""
146
+
147
+ def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
148
+ super().__init__(message, response=response, status_code=int(httpx.codes.TOO_MANY_REQUESTS), request=request)
149
+
150
+
151
+ class InternalServerError(APIStatusError):
152
+ """500+ Server Error.
153
+
154
+ Catches all 5xx status codes. The actual status code (500, 502, 503, etc.)
155
+ is preserved in the status_code attribute for accurate monitoring/alerting.
156
+ """
157
+
158
+ def __init__(self, message: str, *, response: httpx.Response, request: httpx.Request | None = None) -> None:
159
+ super().__init__(message, response=response, status_code=response.status_code, request=request)
@@ -0,0 +1,23 @@
1
+ """Base classes for API resources."""
2
+
3
+ from .client import BaseAsyncAPIClient, BaseSyncAPIClient
4
+
5
+
6
+ class BaseSyncAPIResource:
7
+ """Base class for synchronous API resources."""
8
+
9
+ def __init__(self, client: BaseSyncAPIClient) -> None:
10
+ self._client = client
11
+ self._get = client._get
12
+ self._post = client._post
13
+ self._post_stream = client._post_stream
14
+
15
+
16
+ class BaseAsyncAPIResource:
17
+ """Base class for asynchronous API resources."""
18
+
19
+ def __init__(self, client: BaseAsyncAPIClient) -> None:
20
+ self._client = client
21
+ self._get = client._get
22
+ self._post = client._post
23
+ self._post_stream = client._post_stream
@@ -0,0 +1,62 @@
1
+ """Simple Server-Sent Events (SSE) parser."""
2
+
3
+ import json
4
+ from collections.abc import AsyncIterator, Iterator
5
+ from typing import Any
6
+
7
+ from .exceptions import SSEParseError
8
+
9
+ _DATA_PREFIX = "data:"
10
+
11
+
12
+ def parse_sse_line_stream(lines: Iterator[str]) -> Iterator[dict[str, Any]]:
13
+ """
14
+ Parse SSE stream and yield JSON event data.
15
+
16
+ SSE Format:
17
+ event: STARTED
18
+ data: {"type":"STARTED","run_id":"123",...}
19
+
20
+ event: PROGRESS
21
+ data: {"type":"PROGRESS",...}
22
+
23
+ Yields:
24
+ Parsed JSON objects from 'data:' lines
25
+ """
26
+ for line in lines:
27
+ line = line.strip()
28
+
29
+ # Skip empty lines and comments
30
+ if not line or line.startswith(":"):
31
+ continue
32
+
33
+ # Parse data lines (the actual event payload)
34
+ if line.startswith("data:"):
35
+ data_str = line[len(_DATA_PREFIX) :].strip() # Remove 'data:' prefix
36
+
37
+ try:
38
+ event_data = json.loads(data_str)
39
+ yield event_data
40
+ except json.JSONDecodeError as e:
41
+ raise SSEParseError(f"Malformed JSON in SSE event: {e}", line=line) from e
42
+
43
+ # Ignore 'event:', 'id:', 'retry:' lines for now
44
+ # (we get event type from the JSON data itself)
45
+
46
+
47
+ async def async_parse_sse_line_stream(lines: AsyncIterator[str]) -> AsyncIterator[dict[str, Any]]:
48
+ """Async version of parse_sse_line_stream."""
49
+ async for line in lines:
50
+ line = line.strip()
51
+
52
+ if not line or line.startswith(":"):
53
+ continue
54
+
55
+ if line.startswith("data:"):
56
+ data_str = line[len(_DATA_PREFIX) :].strip()
57
+
58
+ try:
59
+ event_data = json.loads(data_str)
60
+ yield event_data
61
+ except json.JSONDecodeError as e:
62
+ raise SSEParseError(f"Malformed JSON in SSE event: {e}", line=line) from e
@@ -0,0 +1,368 @@
1
+ """Browser automation resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator, Callable, Iterator
6
+ from typing import Any
7
+
8
+ from tinyfish._utils.resource import BaseAsyncAPIResource, BaseSyncAPIResource
9
+ from tinyfish._utils.sse_parser import async_parse_sse_line_stream, parse_sse_line_stream
10
+
11
+ from .types import (
12
+ AgentRunAsyncResponse,
13
+ AgentRunResponse,
14
+ AgentRunWithStreamingResponse,
15
+ BrowserProfile,
16
+ CompleteEvent,
17
+ HeartbeatEvent,
18
+ ProgressEvent,
19
+ ProxyConfig,
20
+ StartedEvent,
21
+ StreamingUrlEvent,
22
+ )
23
+
24
+
25
+ def _build_run_body(
26
+ goal: str,
27
+ url: str,
28
+ browser_profile: BrowserProfile | None,
29
+ proxy_config: ProxyConfig | None,
30
+ ) -> dict[str, Any]:
31
+ body: dict[str, Any] = {"goal": goal, "url": url}
32
+ if browser_profile is not None:
33
+ body["browser_profile"] = browser_profile
34
+ if proxy_config is not None:
35
+ body["proxy_config"] = proxy_config.model_dump(exclude_none=True)
36
+ return body
37
+
38
+
39
+ class AgentStream:
40
+ """Context manager for a synchronous streaming agent run.
41
+
42
+ Use as::
43
+
44
+ with client.agent.stream(goal=..., url=...) as stream:
45
+ for event in stream:
46
+ ...
47
+ """
48
+
49
+ def __init__(self, iterator: Iterator[AgentRunWithStreamingResponse]) -> None:
50
+ self._iterator = iterator
51
+
52
+ def __enter__(self) -> AgentStream:
53
+ return self
54
+
55
+ def __exit__(self, *args: object) -> None:
56
+ self._iterator.close()
57
+
58
+ def __iter__(self) -> Iterator[AgentRunWithStreamingResponse]:
59
+ return self._iterator
60
+
61
+
62
+ class AsyncAgentStream:
63
+ """Context manager for an asynchronous streaming agent run.
64
+
65
+ Use as::
66
+
67
+ async with client.agent.stream(goal=..., url=...) as stream:
68
+ async for event in stream:
69
+ ...
70
+ """
71
+
72
+ def __init__(self, iterator: AsyncIterator[AgentRunWithStreamingResponse]) -> None:
73
+ self._iterator = iterator
74
+
75
+ async def __aenter__(self) -> AsyncAgentStream:
76
+ return self
77
+
78
+ async def __aexit__(self, *args: object) -> None:
79
+ await self._iterator.aclose()
80
+
81
+ def __aiter__(self) -> AsyncIterator[AgentRunWithStreamingResponse]:
82
+ return self._iterator
83
+
84
+
85
+ class AgentResource(BaseSyncAPIResource):
86
+ """Browser automation methods."""
87
+
88
+ def run(
89
+ self,
90
+ *,
91
+ goal: str,
92
+ url: str,
93
+ browser_profile: BrowserProfile | None = None,
94
+ proxy_config: ProxyConfig | None = None,
95
+ ) -> AgentRunResponse:
96
+ """Run a browser automation and wait for it to finish.
97
+
98
+ Blocks until the automation completes or fails. Use `queue()` instead
99
+ if you want to kick off the run and check back later.
100
+
101
+ Args:
102
+ goal: Natural language description of what to do on the page.
103
+ url: The URL to open the browser on.
104
+ browser_profile: "lite" (default) or "stealth" (anti-detection).
105
+ proxy_config: Optional proxy settings (enabled, country_code).
106
+
107
+ Returns:
108
+ AgentRunResponse with status, result, and timing info.
109
+
110
+ Raises:
111
+ AuthenticationError: Invalid API key.
112
+ RateLimitError: Too many requests.
113
+ InternalServerError: Something went wrong on the server.
114
+ """
115
+ body = _build_run_body(goal, url, browser_profile, proxy_config)
116
+ return self._post("/v1/automation/run", json=body, cast_to=AgentRunResponse)
117
+
118
+ def queue(
119
+ self,
120
+ *,
121
+ goal: str,
122
+ url: str,
123
+ browser_profile: BrowserProfile | None = None,
124
+ proxy_config: ProxyConfig | None = None,
125
+ ) -> AgentRunAsyncResponse:
126
+ """Queue a browser automation and return immediately.
127
+
128
+ Does not wait for the run to complete — returns a run_id straight away.
129
+ Use `client.runs.get(run_id)` to poll for the result.
130
+
131
+ Args:
132
+ goal: Natural language description of what to do on the page.
133
+ url: The URL to open the browser on.
134
+ browser_profile: "lite" (default) or "stealth" (anti-detection).
135
+ proxy_config: Optional proxy settings (enabled, country_code).
136
+
137
+ Returns:
138
+ AgentRunAsyncResponse with the run_id to poll later.
139
+
140
+ Raises:
141
+ AuthenticationError: Invalid API key.
142
+ RateLimitError: Too many requests.
143
+ InternalServerError: Something went wrong on the server.
144
+ """
145
+ body = _build_run_body(goal, url, browser_profile, proxy_config)
146
+ return self._post("/v1/automation/run-async", json=body, cast_to=AgentRunAsyncResponse)
147
+
148
+ def stream(
149
+ self,
150
+ *,
151
+ goal: str,
152
+ url: str,
153
+ browser_profile: BrowserProfile | None = None,
154
+ proxy_config: ProxyConfig | None = None,
155
+ on_started: Callable[[StartedEvent], None] | None = None,
156
+ on_streaming_url: Callable[[StreamingUrlEvent], None] | None = None,
157
+ on_progress: Callable[[ProgressEvent], None] | None = None,
158
+ on_heartbeat: Callable[[HeartbeatEvent], None] | None = None,
159
+ on_complete: Callable[[CompleteEvent], None] | None = None,
160
+ ) -> AgentStream:
161
+ """Stream live events from a browser automation run.
162
+
163
+ Returns a context manager that yields SSE events in real time:
164
+ STARTED → STREAMING_URL → PROGRESS (repeated) → COMPLETE.
165
+
166
+ Use the on_* callbacks for a reactive style, or iterate over
167
+ the stream for a sequential style::
168
+
169
+ with client.agent.stream(goal=..., url=...) as stream:
170
+ for event in stream:
171
+ if isinstance(event, ProgressEvent):
172
+ print(event.purpose)
173
+
174
+ Args:
175
+ goal: Natural language description of what to do on the page.
176
+ url: The URL to open the browser on.
177
+ browser_profile: "lite" (default) or "stealth" (anti-detection).
178
+ proxy_config: Optional proxy settings (enabled, country_code).
179
+ on_started: Called when the run starts (receives StartedEvent).
180
+ on_streaming_url: Called with the live browser stream URL (receives StreamingUrlEvent).
181
+ on_progress: Called on each automation step (receives ProgressEvent).
182
+ on_heartbeat: Called on keepalive pings (receives HeartbeatEvent).
183
+ on_complete: Called when the run finishes (receives CompleteEvent).
184
+
185
+ Returns:
186
+ AgentStream context manager — iterate over it to receive events.
187
+
188
+ Raises:
189
+ AuthenticationError: Invalid API key.
190
+ RateLimitError: Too many requests.
191
+ InternalServerError: Something went wrong on the server.
192
+ """
193
+ body = _build_run_body(goal, url, browser_profile, proxy_config)
194
+
195
+ def _generate() -> Iterator[AgentRunWithStreamingResponse]:
196
+ lines = self._post_stream("/v1/automation/run-sse", json=body)
197
+ for event_data in parse_sse_line_stream(lines):
198
+ event_type = event_data.get("type")
199
+ if event_type == "STARTED":
200
+ event = StartedEvent.model_validate(event_data)
201
+ if on_started:
202
+ on_started(event)
203
+ yield event
204
+ elif event_type == "STREAMING_URL":
205
+ event = StreamingUrlEvent.model_validate(event_data)
206
+ if on_streaming_url:
207
+ on_streaming_url(event)
208
+ yield event
209
+ elif event_type == "PROGRESS":
210
+ event = ProgressEvent.model_validate(event_data)
211
+ if on_progress:
212
+ on_progress(event)
213
+ yield event
214
+ elif event_type == "HEARTBEAT":
215
+ event = HeartbeatEvent.model_validate(event_data)
216
+ if on_heartbeat:
217
+ on_heartbeat(event)
218
+ yield event
219
+ elif event_type == "COMPLETE":
220
+ event = CompleteEvent.model_validate(event_data)
221
+ if on_complete:
222
+ on_complete(event)
223
+ yield event
224
+
225
+ return AgentStream(_generate())
226
+
227
+
228
+ class AsyncAgentResource(BaseAsyncAPIResource):
229
+ """Async browser automation methods."""
230
+
231
+ async def run(
232
+ self,
233
+ *,
234
+ goal: str,
235
+ url: str,
236
+ browser_profile: BrowserProfile | None = None,
237
+ proxy_config: ProxyConfig | None = None,
238
+ ) -> AgentRunResponse:
239
+ """Run a browser automation and wait for it to finish.
240
+
241
+ Async version of `AgentResource.run()`. Awaits until the automation
242
+ completes or fails. Use `queue()` instead if you want to fire and poll.
243
+
244
+ Args:
245
+ goal: Natural language description of what to do on the page.
246
+ url: The URL to open the browser on.
247
+ browser_profile: "lite" (default) or "stealth" (anti-detection).
248
+ proxy_config: Optional proxy settings (enabled, country_code).
249
+
250
+ Returns:
251
+ AgentRunResponse with status, result, and timing info.
252
+
253
+ Raises:
254
+ AuthenticationError: Invalid API key.
255
+ RateLimitError: Too many requests.
256
+ InternalServerError: Something went wrong on the server.
257
+ """
258
+ body = _build_run_body(goal, url, browser_profile, proxy_config)
259
+ return await self._post("/v1/automation/run", json=body, cast_to=AgentRunResponse)
260
+
261
+ async def queue(
262
+ self,
263
+ *,
264
+ goal: str,
265
+ url: str,
266
+ browser_profile: BrowserProfile | None = None,
267
+ proxy_config: ProxyConfig | None = None,
268
+ ) -> AgentRunAsyncResponse:
269
+ """Queue a browser automation and return immediately.
270
+
271
+ Async version of `AgentResource.queue()`. Returns a run_id without
272
+ waiting for completion. Use `client.runs.get(run_id)` to poll.
273
+
274
+ Args:
275
+ goal: Natural language description of what to do on the page.
276
+ url: The URL to open the browser on.
277
+ browser_profile: "lite" (default) or "stealth" (anti-detection).
278
+ proxy_config: Optional proxy settings (enabled, country_code).
279
+
280
+ Returns:
281
+ AgentRunAsyncResponse with the run_id to poll later.
282
+
283
+ Raises:
284
+ AuthenticationError: Invalid API key.
285
+ RateLimitError: Too many requests.
286
+ InternalServerError: Something went wrong on the server.
287
+ """
288
+ body = _build_run_body(goal, url, browser_profile, proxy_config)
289
+ return await self._post("/v1/automation/run-async", json=body, cast_to=AgentRunAsyncResponse)
290
+
291
+ def stream(
292
+ self,
293
+ *,
294
+ goal: str,
295
+ url: str,
296
+ browser_profile: BrowserProfile | None = None,
297
+ proxy_config: ProxyConfig | None = None,
298
+ on_started: Callable[[StartedEvent], None] | None = None,
299
+ on_streaming_url: Callable[[StreamingUrlEvent], None] | None = None,
300
+ on_progress: Callable[[ProgressEvent], None] | None = None,
301
+ on_heartbeat: Callable[[HeartbeatEvent], None] | None = None,
302
+ on_complete: Callable[[CompleteEvent], None] | None = None,
303
+ ) -> AsyncAgentStream:
304
+ """Stream live events from a browser automation run.
305
+
306
+ Returns an async context manager that yields SSE events in real time:
307
+ STARTED → STREAMING_URL → PROGRESS (repeated) → COMPLETE.
308
+
309
+ Use the on_* callbacks for a reactive style, or iterate over
310
+ the stream for a sequential style::
311
+
312
+ async with client.agent.stream(goal=..., url=...) as stream:
313
+ async for event in stream:
314
+ if isinstance(event, ProgressEvent):
315
+ print(event.purpose)
316
+
317
+ Args:
318
+ goal: Natural language description of what to do on the page.
319
+ url: The URL to open the browser on.
320
+ browser_profile: "lite" (default) or "stealth" (anti-detection).
321
+ proxy_config: Optional proxy settings (enabled, country_code).
322
+ on_started: Called when the run starts (receives StartedEvent).
323
+ on_streaming_url: Called with the live browser stream URL (receives StreamingUrlEvent).
324
+ on_progress: Called on each automation step (receives ProgressEvent).
325
+ on_heartbeat: Called on keepalive pings (receives HeartbeatEvent).
326
+ on_complete: Called when the run finishes (receives CompleteEvent).
327
+
328
+ Returns:
329
+ AsyncAgentStream context manager — async-iterate over it to receive events.
330
+
331
+ Raises:
332
+ AuthenticationError: Invalid API key.
333
+ RateLimitError: Too many requests.
334
+ InternalServerError: Something went wrong on the server.
335
+ """
336
+ body = _build_run_body(goal, url, browser_profile, proxy_config)
337
+
338
+ async def _generate() -> AsyncIterator[AgentRunWithStreamingResponse]:
339
+ lines = self._post_stream("/v1/automation/run-sse", json=body)
340
+ async for event_data in async_parse_sse_line_stream(lines):
341
+ event_type = event_data.get("type")
342
+ if event_type == "STARTED":
343
+ event = StartedEvent.model_validate(event_data)
344
+ if on_started:
345
+ on_started(event)
346
+ yield event
347
+ elif event_type == "STREAMING_URL":
348
+ event = StreamingUrlEvent.model_validate(event_data)
349
+ if on_streaming_url:
350
+ on_streaming_url(event)
351
+ yield event
352
+ elif event_type == "PROGRESS":
353
+ event = ProgressEvent.model_validate(event_data)
354
+ if on_progress:
355
+ on_progress(event)
356
+ yield event
357
+ elif event_type == "HEARTBEAT":
358
+ event = HeartbeatEvent.model_validate(event_data)
359
+ if on_heartbeat:
360
+ on_heartbeat(event)
361
+ yield event
362
+ elif event_type == "COMPLETE":
363
+ event = CompleteEvent.model_validate(event_data)
364
+ if on_complete:
365
+ on_complete(event)
366
+ yield event
367
+
368
+ return AsyncAgentStream(_generate())