smooth-py 0.3.0__py3-none-any.whl → 0.3.5.dev20251110__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.
Potentially problematic release.
This version of smooth-py might be problematic. Click here for more details.
smooth/__init__.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
# pyright: reportPrivateUsage=false
|
|
1
2
|
"""Smooth python SDK."""
|
|
2
3
|
|
|
3
4
|
import asyncio
|
|
5
|
+
import base64
|
|
4
6
|
import io
|
|
5
7
|
import logging
|
|
6
8
|
import os
|
|
@@ -8,7 +10,7 @@ import time
|
|
|
8
10
|
import urllib.parse
|
|
9
11
|
import warnings
|
|
10
12
|
from pathlib import Path
|
|
11
|
-
from typing import Any, Literal, Type
|
|
13
|
+
from typing import Any, Literal, NotRequired, Type, TypedDict
|
|
12
14
|
|
|
13
15
|
import httpx
|
|
14
16
|
import requests
|
|
@@ -26,956 +28,1494 @@ BASE_URL = "https://api.smooth.sh/api/"
|
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
def _encode_url(url: str, interactive: bool = True, embed: bool = False) -> str:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
parsed_url = urllib.parse.urlparse(url)
|
|
32
|
+
params = urllib.parse.parse_qs(parsed_url.query)
|
|
33
|
+
params.update(
|
|
34
|
+
{
|
|
35
|
+
"interactive": "true" if interactive else "false",
|
|
36
|
+
"embed": "true" if embed else "false",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
return urllib.parse.urlunparse(
|
|
40
|
+
parsed_url._replace(query=urllib.parse.urlencode(params))
|
|
41
|
+
)
|
|
33
42
|
|
|
34
43
|
|
|
35
44
|
# --- Models ---
|
|
36
|
-
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Certificate(TypedDict):
|
|
48
|
+
"""Client certificate for accessing secure websites.
|
|
49
|
+
|
|
50
|
+
Attributes:
|
|
51
|
+
file: p12 file object to be uploaded (e.g., open("cert.p12", "rb")).
|
|
52
|
+
password: Password to decrypt the certificate file. Optional.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
file: str | io.IOBase # Required - base64 string or binary IO
|
|
56
|
+
password: NotRequired[str] # Optional
|
|
57
|
+
filters: NotRequired[
|
|
58
|
+
list[str]
|
|
59
|
+
] # Optional - TODO: Reserved for future use to specify URL patterns where the certificate should be applied.
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _process_certificates(
|
|
63
|
+
certificates: list[Certificate] | None,
|
|
64
|
+
) -> list[dict[str, Any]] | None:
|
|
65
|
+
"""Process certificates, converting binary IO to base64-encoded strings.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
certificates: List of certificates with file field as string or binary IO.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
List of certificates with file field as base64-encoded string, or None if input is None.
|
|
72
|
+
"""
|
|
73
|
+
if certificates is None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
processed_certs: list[dict[str, Any]] = []
|
|
77
|
+
for cert in certificates:
|
|
78
|
+
processed_cert = dict(cert) # Create a copy
|
|
79
|
+
|
|
80
|
+
file_content = processed_cert["file"]
|
|
81
|
+
if isinstance(file_content, io.IOBase):
|
|
82
|
+
# Read the binary content and encode to base64
|
|
83
|
+
binary_data = file_content.read()
|
|
84
|
+
processed_cert["file"] = base64.b64encode(binary_data).decode("utf-8")
|
|
85
|
+
elif not isinstance(file_content, str):
|
|
86
|
+
raise TypeError(
|
|
87
|
+
f"Certificate file must be a string or binary IO, got {type(file_content)}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
processed_certs.append(processed_cert)
|
|
91
|
+
|
|
92
|
+
return processed_certs
|
|
37
93
|
|
|
38
94
|
|
|
39
95
|
class TaskResponse(BaseModel):
|
|
40
|
-
|
|
96
|
+
"""Task response model."""
|
|
41
97
|
|
|
42
|
-
|
|
98
|
+
model_config = ConfigDict(extra="allow")
|
|
43
99
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
100
|
+
id: str = Field(description="The ID of the task.")
|
|
101
|
+
status: Literal["waiting", "running", "done", "failed", "cancelled"] = Field(
|
|
102
|
+
description="The status of the task."
|
|
103
|
+
)
|
|
104
|
+
output: Any | None = Field(default=None, description="The output of the task.")
|
|
105
|
+
credits_used: int | None = Field(
|
|
106
|
+
default=None, description="The amount of credits used to perform the task."
|
|
107
|
+
)
|
|
108
|
+
device: Literal["desktop", "mobile"] | None = Field(
|
|
109
|
+
default=None, description="The device type used for the task."
|
|
110
|
+
)
|
|
111
|
+
live_url: str | None = Field(
|
|
112
|
+
default=None,
|
|
113
|
+
description="The URL to view and interact with the task execution.",
|
|
114
|
+
)
|
|
115
|
+
recording_url: str | None = Field(
|
|
116
|
+
default=None, description="The URL to view the task recording."
|
|
117
|
+
)
|
|
118
|
+
downloads_url: str | None = Field(
|
|
119
|
+
default=None,
|
|
120
|
+
description="The URL of the archive containing the downloaded files.",
|
|
121
|
+
)
|
|
122
|
+
created_at: int | None = Field(
|
|
123
|
+
default=None, description="The timestamp when the task was created."
|
|
124
|
+
)
|
|
51
125
|
|
|
52
126
|
|
|
53
127
|
class TaskRequest(BaseModel):
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
model_config = ConfigDict(extra="allow")
|
|
57
|
-
|
|
58
|
-
task: str = Field(description="The task to run.")
|
|
59
|
-
response_model: dict[str, Any] | None = Field(
|
|
60
|
-
default=None, description="If provided, the JSON schema describing the desired output structure. Default is None"
|
|
61
|
-
)
|
|
62
|
-
url: str | None = Field(
|
|
63
|
-
default=None,
|
|
64
|
-
description="The starting URL for the task. If not provided, the agent will infer it from the task.",
|
|
65
|
-
)
|
|
66
|
-
metadata: dict[str, str | int | float | bool] | None = Field(
|
|
67
|
-
default=None, description="A dictionary containing variables or parameters that will be passed to the agent."
|
|
68
|
-
)
|
|
69
|
-
files: list[str] | None = Field(default=None, description="A list of file ids to pass to the agent.")
|
|
70
|
-
agent: Literal["smooth", "smooth-lite"] = Field(default="smooth", description="The agent to use for the task.")
|
|
71
|
-
max_steps: int = Field(default=32, ge=2, le=128, description="Maximum number of steps the agent can take (min 2, max 128).")
|
|
72
|
-
device: Literal["desktop", "mobile"] = Field(default="mobile", description="Device type for the task. Default is mobile.")
|
|
73
|
-
allowed_urls: list[str] | None = Field(
|
|
74
|
-
default=None,
|
|
75
|
-
description=(
|
|
76
|
-
"List of allowed URL patterns using wildcard syntax (e.g., https://*example.com/*). If None, all URLs are allowed."
|
|
77
|
-
),
|
|
78
|
-
)
|
|
79
|
-
enable_recording: bool = Field(default=True, description="Enable video recording of the task execution. Default is True")
|
|
80
|
-
profile_id: str | None = Field(
|
|
81
|
-
default=None,
|
|
82
|
-
description=("Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials."),
|
|
83
|
-
)
|
|
84
|
-
profile_read_only: bool = Field(
|
|
85
|
-
default=False, description="If true, the profile specified by `profile_id` will be loaded in read-only mode."
|
|
86
|
-
)
|
|
87
|
-
stealth_mode: bool = Field(default=False, description="Run the browser in stealth mode.")
|
|
88
|
-
proxy_server: str | None = Field(
|
|
89
|
-
default=None,
|
|
90
|
-
description=(
|
|
91
|
-
"Proxy server url to route browser traffic through. Must include the protocol to use (e.g. http:// or https://)"
|
|
92
|
-
),
|
|
93
|
-
)
|
|
94
|
-
proxy_username: str | None = Field(default=None, description="Proxy server username.")
|
|
95
|
-
proxy_password: str | None = Field(default=None, description="Proxy server password.")
|
|
96
|
-
experimental_features: dict[str, Any] | None = Field(
|
|
97
|
-
default=None, description="Experimental features to enable for the task."
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
@model_validator(mode="before")
|
|
101
|
-
@classmethod
|
|
102
|
-
def _handle_deprecated_session_id(cls, data: Any) -> Any:
|
|
103
|
-
if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
|
|
104
|
-
warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
|
|
105
|
-
data["profile_id"] = data.pop("session_id")
|
|
106
|
-
return data
|
|
107
|
-
|
|
108
|
-
@computed_field(return_type=str | None)
|
|
109
|
-
@property
|
|
110
|
-
def session_id(self):
|
|
111
|
-
"""(Deprecated) Returns the session ID."""
|
|
112
|
-
warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
|
|
113
|
-
return self.profile_id
|
|
114
|
-
|
|
115
|
-
@session_id.setter
|
|
116
|
-
def session_id(self, value: str | None):
|
|
117
|
-
"""(Deprecated) Sets the session ID."""
|
|
118
|
-
warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
|
|
119
|
-
self.profile_id = value
|
|
120
|
-
|
|
121
|
-
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
|
122
|
-
"""Dump model to dict, including deprecated session_id for retrocompatibility."""
|
|
123
|
-
data = super().model_dump(**kwargs)
|
|
124
|
-
# Add deprecated session_id field for retrocompatibility
|
|
125
|
-
if "profile_id" in data:
|
|
126
|
-
data["session_id"] = data["profile_id"]
|
|
127
|
-
return data
|
|
128
|
+
"""Run task request model."""
|
|
128
129
|
|
|
130
|
+
model_config = ConfigDict(extra="allow")
|
|
129
131
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
"""
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
132
|
+
task: str = Field(description="The task to run.")
|
|
133
|
+
response_model: dict[str, Any] | None = Field(
|
|
134
|
+
default=None,
|
|
135
|
+
description="If provided, the JSON schema describing the desired output structure. Default is None",
|
|
136
|
+
)
|
|
137
|
+
url: str | None = Field(
|
|
138
|
+
default=None,
|
|
139
|
+
description="The starting URL for the task. If not provided, the agent will infer it from the task.",
|
|
140
|
+
)
|
|
141
|
+
metadata: dict[str, str | int | float | bool] | None = Field(
|
|
142
|
+
default=None,
|
|
143
|
+
description="A dictionary containing variables or parameters that will be passed to the agent.",
|
|
144
|
+
)
|
|
145
|
+
files: list[str] | None = Field(
|
|
146
|
+
default=None, description="A list of file ids to pass to the agent."
|
|
147
|
+
)
|
|
148
|
+
agent: Literal["smooth", "smooth-lite"] = Field(
|
|
149
|
+
default="smooth", description="The agent to use for the task."
|
|
150
|
+
)
|
|
151
|
+
max_steps: int = Field(
|
|
152
|
+
default=32,
|
|
153
|
+
ge=2,
|
|
154
|
+
le=128,
|
|
155
|
+
description="Maximum number of steps the agent can take (min 2, max 128).",
|
|
156
|
+
)
|
|
157
|
+
device: Literal["desktop", "mobile"] = Field(
|
|
158
|
+
default="desktop", description="Device type for the task. Default is desktop."
|
|
159
|
+
)
|
|
160
|
+
allowed_urls: list[str] | None = Field(
|
|
161
|
+
default=None,
|
|
162
|
+
description=(
|
|
163
|
+
"List of allowed URL patterns using wildcard syntax (e.g., https://*example.com/*). If None, all URLs are allowed."
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
enable_recording: bool = Field(
|
|
167
|
+
default=True,
|
|
168
|
+
description="Enable video recording of the task execution. Default is True",
|
|
169
|
+
)
|
|
170
|
+
profile_id: str | None = Field(
|
|
171
|
+
default=None,
|
|
172
|
+
description=(
|
|
173
|
+
"Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials."
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
profile_read_only: bool = Field(
|
|
177
|
+
default=False,
|
|
178
|
+
description=(
|
|
179
|
+
"If true, the profile specified by `profile_id` will be loaded in read-only mode. "
|
|
180
|
+
"Changes made during the task will not be saved back to the profile."
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
stealth_mode: bool = Field(
|
|
184
|
+
default=False, description="Run the browser in stealth mode."
|
|
185
|
+
)
|
|
186
|
+
proxy_server: str | None = Field(
|
|
187
|
+
default=None,
|
|
188
|
+
description=(
|
|
189
|
+
"Proxy server url to route browser traffic through."
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
proxy_username: str | None = Field(
|
|
193
|
+
default=None, description="Proxy server username."
|
|
194
|
+
)
|
|
195
|
+
proxy_password: str | None = Field(
|
|
196
|
+
default=None, description="Proxy server password."
|
|
197
|
+
)
|
|
198
|
+
certificates: list[dict[str, Any]] | None = Field(
|
|
199
|
+
default=None,
|
|
200
|
+
description=(
|
|
201
|
+
"List of client certificates to use when accessing secure websites. "
|
|
202
|
+
"Each certificate is a dictionary with the following fields:\n"
|
|
203
|
+
" - `file`: p12 file object to be uploaded (e.g., open('cert.p12', 'rb')).\n"
|
|
204
|
+
" - `password` (optional): Password to decrypt the certificate file."
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
use_adblock: bool | None = Field(
|
|
208
|
+
default=True,
|
|
209
|
+
description="Enable adblock for the browser session. Default is True.",
|
|
210
|
+
)
|
|
211
|
+
additional_tools: dict[str, dict[str, Any] | None] | None = Field(
|
|
212
|
+
default=None, description="Additional tools to enable for the task."
|
|
213
|
+
)
|
|
214
|
+
experimental_features: dict[str, Any] | None = Field(
|
|
215
|
+
default=None, description="Experimental features to enable for the task."
|
|
216
|
+
)
|
|
217
|
+
extensions: list[str] | None = Field(
|
|
218
|
+
default=None, description="List of extensions to install for the task."
|
|
219
|
+
)
|
|
166
220
|
|
|
221
|
+
@model_validator(mode="before")
|
|
222
|
+
@classmethod
|
|
223
|
+
def _handle_deprecated_session_id(cls, data: Any) -> Any:
|
|
224
|
+
if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
|
|
225
|
+
warnings.warn(
|
|
226
|
+
"'session_id' is deprecated, use 'profile_id' instead",
|
|
227
|
+
DeprecationWarning,
|
|
228
|
+
stacklevel=2,
|
|
229
|
+
)
|
|
230
|
+
data["profile_id"] = data.pop("session_id")
|
|
231
|
+
return data
|
|
232
|
+
|
|
233
|
+
@computed_field(return_type=str | None)
|
|
234
|
+
@property
|
|
235
|
+
def session_id(self):
|
|
236
|
+
"""(Deprecated) Returns the session ID."""
|
|
237
|
+
warnings.warn(
|
|
238
|
+
"'session_id' is deprecated, use 'profile_id' instead",
|
|
239
|
+
DeprecationWarning,
|
|
240
|
+
stacklevel=2,
|
|
241
|
+
)
|
|
242
|
+
return self.profile_id
|
|
243
|
+
|
|
244
|
+
@session_id.setter
|
|
245
|
+
def session_id(self, value: str | None):
|
|
246
|
+
"""(Deprecated) Sets the session ID."""
|
|
247
|
+
warnings.warn(
|
|
248
|
+
"'session_id' is deprecated, use 'profile_id' instead",
|
|
249
|
+
DeprecationWarning,
|
|
250
|
+
stacklevel=2,
|
|
251
|
+
)
|
|
252
|
+
self.profile_id = value
|
|
253
|
+
|
|
254
|
+
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
|
255
|
+
"""Dump model to dict, including deprecated session_id for retrocompatibility."""
|
|
256
|
+
data = super().model_dump(**kwargs)
|
|
257
|
+
# Add deprecated session_id field for retrocompatibility
|
|
258
|
+
if "profile_id" in data:
|
|
259
|
+
data["session_id"] = data["profile_id"]
|
|
260
|
+
return data
|
|
167
261
|
|
|
168
|
-
class BrowserSessionResponse(BaseModel):
|
|
169
|
-
"""Browser session response model."""
|
|
170
|
-
|
|
171
|
-
profile_id: str = Field(description="The ID of the browser profile associated with the opened browser instance.")
|
|
172
|
-
live_id: str | None = Field(description="The ID of the live browser session.")
|
|
173
|
-
live_url: str | None = Field(default=None, description="The live URL to interact with the browser session.")
|
|
174
|
-
|
|
175
|
-
@model_validator(mode="before")
|
|
176
|
-
@classmethod
|
|
177
|
-
def _handle_deprecated_session_id(cls, data: Any) -> Any:
|
|
178
|
-
if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
|
|
179
|
-
warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
|
|
180
|
-
data["profile_id"] = data.pop("session_id")
|
|
181
|
-
return data
|
|
182
|
-
|
|
183
|
-
@computed_field(return_type=str | None)
|
|
184
|
-
@property
|
|
185
|
-
def session_id(self):
|
|
186
|
-
"""(Deprecated) Returns the session ID."""
|
|
187
|
-
warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
|
|
188
|
-
return self.profile_id
|
|
189
|
-
|
|
190
|
-
@session_id.setter
|
|
191
|
-
def session_id(self, value: str):
|
|
192
|
-
"""(Deprecated) Sets the session ID."""
|
|
193
|
-
warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
|
|
194
|
-
self.profile_id = value
|
|
195
262
|
|
|
263
|
+
class BrowserSessionRequest(BaseModel):
|
|
264
|
+
"""Request model for creating a browser session."""
|
|
196
265
|
|
|
197
|
-
|
|
198
|
-
"""Response model for listing browser profiles."""
|
|
199
|
-
|
|
200
|
-
profile_ids: list[str] = Field(description="The IDs of the browser profiles.")
|
|
201
|
-
|
|
202
|
-
@model_validator(mode="before")
|
|
203
|
-
@classmethod
|
|
204
|
-
def _handle_deprecated_session_ids(cls, data: Any) -> Any:
|
|
205
|
-
if isinstance(data, dict) and "session_ids" in data and "profile_ids" not in data:
|
|
206
|
-
warnings.warn("'session_ids' is deprecated, use 'profile_ids' instead", DeprecationWarning, stacklevel=2)
|
|
207
|
-
data["profile_ids"] = data.pop("session_ids")
|
|
208
|
-
return data
|
|
209
|
-
|
|
210
|
-
@computed_field(return_type=list[str])
|
|
211
|
-
@property
|
|
212
|
-
def session_ids(self):
|
|
213
|
-
"""(Deprecated) Returns the session IDs."""
|
|
214
|
-
warnings.warn("'session_ids' is deprecated, use 'profile_ids' instead", DeprecationWarning, stacklevel=2)
|
|
215
|
-
return self.profile_ids
|
|
216
|
-
|
|
217
|
-
@session_ids.setter
|
|
218
|
-
def session_ids(self, value: list[str]):
|
|
219
|
-
"""(Deprecated) Sets the session IDs."""
|
|
220
|
-
warnings.warn("'session_ids' is deprecated, use 'profile_ids' instead", DeprecationWarning, stacklevel=2)
|
|
221
|
-
self.profile_ids = value
|
|
222
|
-
|
|
223
|
-
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
|
224
|
-
"""Dump model to dict, including deprecated session_ids for retrocompatibility."""
|
|
225
|
-
data = super().model_dump(**kwargs)
|
|
226
|
-
# Add deprecated session_ids field for retrocompatibility
|
|
227
|
-
if "profile_ids" in data:
|
|
228
|
-
data["session_ids"] = data["profile_ids"]
|
|
229
|
-
return data
|
|
266
|
+
model_config = ConfigDict(extra="allow")
|
|
230
267
|
|
|
268
|
+
profile_id: str | None = Field(
|
|
269
|
+
default=None,
|
|
270
|
+
description=(
|
|
271
|
+
"The profile ID to use for the browser session. If None, a new profile will be created."
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
live_view: bool | None = Field(
|
|
275
|
+
default=True,
|
|
276
|
+
description="Request a live URL to interact with the browser session.",
|
|
277
|
+
)
|
|
278
|
+
device: Literal["desktop", "mobile"] | None = Field(
|
|
279
|
+
default="desktop", description="The device type to use."
|
|
280
|
+
)
|
|
281
|
+
url: str | None = Field(
|
|
282
|
+
default=None, description="The URL to open in the browser session."
|
|
283
|
+
)
|
|
284
|
+
proxy_server: str | None = Field(
|
|
285
|
+
default=None,
|
|
286
|
+
description=(
|
|
287
|
+
"Proxy server address to route browser traffic through."
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
proxy_username: str | None = Field(
|
|
291
|
+
default=None, description="Proxy server username."
|
|
292
|
+
)
|
|
293
|
+
proxy_password: str | None = Field(
|
|
294
|
+
default=None, description="Proxy server password."
|
|
295
|
+
)
|
|
231
296
|
|
|
232
|
-
|
|
233
|
-
|
|
297
|
+
@model_validator(mode="before")
|
|
298
|
+
@classmethod
|
|
299
|
+
def _handle_deprecated_session_id(cls, data: Any) -> Any:
|
|
300
|
+
if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
|
|
301
|
+
warnings.warn(
|
|
302
|
+
"'session_id' is deprecated, use 'profile_id' instead",
|
|
303
|
+
DeprecationWarning,
|
|
304
|
+
stacklevel=2,
|
|
305
|
+
)
|
|
306
|
+
data["profile_id"] = data.pop("session_id")
|
|
307
|
+
return data
|
|
308
|
+
|
|
309
|
+
@computed_field(return_type=str | None)
|
|
310
|
+
@property
|
|
311
|
+
def session_id(self):
|
|
312
|
+
"""(Deprecated) Returns the session ID."""
|
|
313
|
+
warnings.warn(
|
|
314
|
+
"'session_id' is deprecated, use 'profile_id' instead",
|
|
315
|
+
DeprecationWarning,
|
|
316
|
+
stacklevel=2,
|
|
317
|
+
)
|
|
318
|
+
return self.profile_id
|
|
319
|
+
|
|
320
|
+
@session_id.setter
|
|
321
|
+
def session_id(self, value: str | None):
|
|
322
|
+
"""(Deprecated) Sets the session ID."""
|
|
323
|
+
warnings.warn(
|
|
324
|
+
"'session_id' is deprecated, use 'profile_id' instead",
|
|
325
|
+
DeprecationWarning,
|
|
326
|
+
stacklevel=2,
|
|
327
|
+
)
|
|
328
|
+
self.profile_id = value
|
|
329
|
+
|
|
330
|
+
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
|
331
|
+
"""Dump model to dict, including deprecated session_id for retrocompatibility."""
|
|
332
|
+
data = super().model_dump(**kwargs)
|
|
333
|
+
# Add deprecated session_id field for retrocompatibility
|
|
334
|
+
if "profile_id" in data:
|
|
335
|
+
data["session_id"] = data["profile_id"]
|
|
336
|
+
return data
|
|
234
337
|
|
|
235
|
-
pass
|
|
236
338
|
|
|
339
|
+
class BrowserSessionResponse(BaseModel):
|
|
340
|
+
"""Browser session response model."""
|
|
237
341
|
|
|
238
|
-
|
|
239
|
-
"""Response model for uploading a file."""
|
|
342
|
+
model_config = ConfigDict(extra="allow")
|
|
240
343
|
|
|
241
|
-
|
|
344
|
+
profile_id: str = Field(
|
|
345
|
+
description="The ID of the browser profile associated with the opened browser instance."
|
|
346
|
+
)
|
|
347
|
+
live_id: str | None = Field(default=None, description="The ID of the live browser session.")
|
|
348
|
+
live_url: str | None = Field(
|
|
349
|
+
default=None, description="The live URL to interact with the browser session."
|
|
350
|
+
)
|
|
242
351
|
|
|
352
|
+
@model_validator(mode="before")
|
|
353
|
+
@classmethod
|
|
354
|
+
def _handle_deprecated_session_id(cls, data: Any) -> Any:
|
|
355
|
+
if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
|
|
356
|
+
warnings.warn(
|
|
357
|
+
"'session_id' is deprecated, use 'profile_id' instead",
|
|
358
|
+
DeprecationWarning,
|
|
359
|
+
stacklevel=2,
|
|
360
|
+
)
|
|
361
|
+
data["profile_id"] = data.pop("session_id")
|
|
362
|
+
return data
|
|
363
|
+
|
|
364
|
+
@computed_field(return_type=str | None)
|
|
365
|
+
@property
|
|
366
|
+
def session_id(self):
|
|
367
|
+
"""(Deprecated) Returns the session ID."""
|
|
368
|
+
warnings.warn(
|
|
369
|
+
"'session_id' is deprecated, use 'profile_id' instead",
|
|
370
|
+
DeprecationWarning,
|
|
371
|
+
stacklevel=2,
|
|
372
|
+
)
|
|
373
|
+
return self.profile_id
|
|
374
|
+
|
|
375
|
+
@session_id.setter
|
|
376
|
+
def session_id(self, value: str):
|
|
377
|
+
"""(Deprecated) Sets the session ID."""
|
|
378
|
+
warnings.warn(
|
|
379
|
+
"'session_id' is deprecated, use 'profile_id' instead",
|
|
380
|
+
DeprecationWarning,
|
|
381
|
+
stacklevel=2,
|
|
382
|
+
)
|
|
383
|
+
self.profile_id = value
|
|
243
384
|
|
|
244
|
-
# --- Exception Handling ---
|
|
245
385
|
|
|
386
|
+
class BrowserProfilesResponse(BaseModel):
|
|
387
|
+
"""Response model for listing browser profiles."""
|
|
388
|
+
|
|
389
|
+
model_config = ConfigDict(extra="allow")
|
|
390
|
+
|
|
391
|
+
profile_ids: list[str] = Field(description="The IDs of the browser profiles.")
|
|
392
|
+
|
|
393
|
+
@model_validator(mode="before")
|
|
394
|
+
@classmethod
|
|
395
|
+
def _handle_deprecated_session_ids(cls, data: Any) -> Any:
|
|
396
|
+
if (
|
|
397
|
+
isinstance(data, dict)
|
|
398
|
+
and "session_ids" in data
|
|
399
|
+
and "profile_ids" not in data
|
|
400
|
+
):
|
|
401
|
+
warnings.warn(
|
|
402
|
+
"'session_ids' is deprecated, use 'profile_ids' instead",
|
|
403
|
+
DeprecationWarning,
|
|
404
|
+
stacklevel=2,
|
|
405
|
+
)
|
|
406
|
+
data["profile_ids"] = data.pop("session_ids")
|
|
407
|
+
return data
|
|
408
|
+
|
|
409
|
+
@computed_field(return_type=list[str])
|
|
410
|
+
@property
|
|
411
|
+
def session_ids(self):
|
|
412
|
+
"""(Deprecated) Returns the session IDs."""
|
|
413
|
+
warnings.warn(
|
|
414
|
+
"'session_ids' is deprecated, use 'profile_ids' instead",
|
|
415
|
+
DeprecationWarning,
|
|
416
|
+
stacklevel=2,
|
|
417
|
+
)
|
|
418
|
+
return self.profile_ids
|
|
419
|
+
|
|
420
|
+
@session_ids.setter
|
|
421
|
+
def session_ids(self, value: list[str]):
|
|
422
|
+
"""(Deprecated) Sets the session IDs."""
|
|
423
|
+
warnings.warn(
|
|
424
|
+
"'session_ids' is deprecated, use 'profile_ids' instead",
|
|
425
|
+
DeprecationWarning,
|
|
426
|
+
stacklevel=2,
|
|
427
|
+
)
|
|
428
|
+
self.profile_ids = value
|
|
429
|
+
|
|
430
|
+
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
|
431
|
+
"""Dump model to dict, including deprecated session_ids for retrocompatibility."""
|
|
432
|
+
data = super().model_dump(**kwargs)
|
|
433
|
+
# Add deprecated session_ids field for retrocompatibility
|
|
434
|
+
if "profile_ids" in data:
|
|
435
|
+
data["session_ids"] = data["profile_ids"]
|
|
436
|
+
return data
|
|
246
437
|
|
|
247
|
-
class ApiError(Exception):
|
|
248
|
-
"""Custom exception for API errors."""
|
|
249
438
|
|
|
250
|
-
|
|
251
|
-
"""
|
|
252
|
-
self.status_code = status_code
|
|
253
|
-
self.detail = detail
|
|
254
|
-
self.response_data = response_data
|
|
255
|
-
super().__init__(f"API Error {status_code}: {detail}")
|
|
439
|
+
class BrowserSessionsResponse(BrowserProfilesResponse):
|
|
440
|
+
"""Response model for listing browser profiles."""
|
|
256
441
|
|
|
442
|
+
pass
|
|
257
443
|
|
|
258
|
-
class TimeoutError(Exception):
|
|
259
|
-
"""Custom exception for task timeouts."""
|
|
260
444
|
|
|
261
|
-
|
|
445
|
+
class UploadFileResponse(BaseModel):
|
|
446
|
+
"""Response model for uploading a file."""
|
|
262
447
|
|
|
448
|
+
model_config = ConfigDict(extra="allow")
|
|
263
449
|
|
|
264
|
-
|
|
450
|
+
id: str = Field(description="The ID assigned to the uploaded file.")
|
|
265
451
|
|
|
266
452
|
|
|
267
|
-
class
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def __init__(self, api_key: str | None = None, base_url: str = BASE_URL, api_version: str = "v1"):
|
|
271
|
-
"""Initializes the base client."""
|
|
272
|
-
# Try to get API key from environment if not provided
|
|
273
|
-
if not api_key:
|
|
274
|
-
api_key = os.getenv("CIRCLEMIND_API_KEY")
|
|
275
|
-
|
|
276
|
-
if not api_key:
|
|
277
|
-
raise ValueError("API key is required. Provide it directly or set CIRCLEMIND_API_KEY environment variable.")
|
|
278
|
-
|
|
279
|
-
if not base_url:
|
|
280
|
-
raise ValueError("Base URL cannot be empty.")
|
|
281
|
-
|
|
282
|
-
self.api_key = api_key
|
|
283
|
-
self.base_url = f"{base_url.rstrip('/')}/{api_version}"
|
|
284
|
-
self.headers = {
|
|
285
|
-
"apikey": self.api_key,
|
|
286
|
-
"User-Agent": "smooth-python-sdk/0.2.5",
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
def _handle_response(self, response: requests.Response | httpx.Response) -> dict[str, Any]:
|
|
290
|
-
"""Handles HTTP responses and raises exceptions for errors."""
|
|
291
|
-
if 200 <= response.status_code < 300:
|
|
292
|
-
try:
|
|
293
|
-
return response.json()
|
|
294
|
-
except ValueError as e:
|
|
295
|
-
logger.error(f"Failed to parse JSON response: {e}")
|
|
296
|
-
raise ApiError(status_code=response.status_code, detail="Invalid JSON response from server") from None
|
|
297
|
-
|
|
298
|
-
# Handle error responses
|
|
299
|
-
error_data = None
|
|
300
|
-
try:
|
|
301
|
-
error_data = response.json()
|
|
302
|
-
detail = error_data.get("detail", response.text)
|
|
303
|
-
except ValueError:
|
|
304
|
-
detail = response.text or f"HTTP {response.status_code} error"
|
|
305
|
-
|
|
306
|
-
logger.error(f"API error: {response.status_code} - {detail}")
|
|
307
|
-
raise ApiError(status_code=response.status_code, detail=detail, response_data=error_data)
|
|
453
|
+
class UploadExtensionResponse(BaseModel):
|
|
454
|
+
"""Response model for uploading an extension."""
|
|
308
455
|
|
|
456
|
+
model_config = ConfigDict(extra="allow")
|
|
309
457
|
|
|
310
|
-
|
|
458
|
+
id: str = Field(description="The uploaded extension ID.")
|
|
311
459
|
|
|
312
460
|
|
|
313
|
-
class
|
|
314
|
-
|
|
461
|
+
class Extension(BaseModel):
|
|
462
|
+
"""Extension model."""
|
|
315
463
|
|
|
316
|
-
|
|
464
|
+
model_config = ConfigDict(extra="allow")
|
|
317
465
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
return self.profile_id()
|
|
466
|
+
id: str = Field(description="The ID of the extension.")
|
|
467
|
+
file_name: str = Field(description="The name of the extension.")
|
|
468
|
+
creation_time: int = Field(description="The creation timestamp.")
|
|
322
469
|
|
|
323
|
-
def profile_id(self):
|
|
324
|
-
"""Returns the profile ID for the browser session."""
|
|
325
|
-
return self.browser_session.profile_id
|
|
326
470
|
|
|
327
|
-
|
|
328
|
-
"""
|
|
329
|
-
if self.browser_session.live_url:
|
|
330
|
-
return _encode_url(self.browser_session.live_url, interactive=interactive, embed=embed)
|
|
331
|
-
return None
|
|
471
|
+
class ListExtensionsResponse(BaseModel):
|
|
472
|
+
"""Response model for listing extensions."""
|
|
332
473
|
|
|
474
|
+
model_config = ConfigDict(extra="allow")
|
|
333
475
|
|
|
334
|
-
|
|
335
|
-
"""A handle to a running task."""
|
|
336
|
-
|
|
337
|
-
def __init__(self, task_id: str, client: "SmoothClient"):
|
|
338
|
-
"""Initializes the task handle."""
|
|
339
|
-
self._client = client
|
|
340
|
-
self._task_response: TaskResponse | None = None
|
|
341
|
-
|
|
342
|
-
self._id = task_id
|
|
343
|
-
|
|
344
|
-
def id(self):
|
|
345
|
-
"""Returns the task ID."""
|
|
346
|
-
return self._id
|
|
347
|
-
|
|
348
|
-
def stop(self):
|
|
349
|
-
"""Stops the task."""
|
|
350
|
-
self._client._delete_task(self._id)
|
|
351
|
-
|
|
352
|
-
def result(self, timeout: int | None = None, poll_interval: float = 1) -> TaskResponse:
|
|
353
|
-
"""Waits for the task to complete and returns the result."""
|
|
354
|
-
if self._task_response and self._task_response.status not in ["running", "waiting"]:
|
|
355
|
-
return self._task_response
|
|
356
|
-
|
|
357
|
-
if timeout is not None and timeout < 1:
|
|
358
|
-
raise ValueError("Timeout must be at least 1 second.")
|
|
359
|
-
if poll_interval < 0.1:
|
|
360
|
-
raise ValueError("Poll interval must be at least 100 milliseconds.")
|
|
361
|
-
|
|
362
|
-
start_time = time.time()
|
|
363
|
-
while timeout is None or (time.time() - start_time) < timeout:
|
|
364
|
-
task_response = self._client._get_task(self.id())
|
|
365
|
-
self._task_response = task_response
|
|
366
|
-
if task_response.status not in ["running", "waiting"]:
|
|
367
|
-
return task_response
|
|
368
|
-
time.sleep(poll_interval)
|
|
369
|
-
raise TimeoutError(f"Task {self.id()} did not complete within {timeout} seconds.")
|
|
370
|
-
|
|
371
|
-
def live_url(self, interactive: bool = False, embed: bool = False, timeout: int | None = None):
|
|
372
|
-
"""Returns the live URL for the task."""
|
|
373
|
-
if self._task_response and self._task_response.live_url:
|
|
374
|
-
return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
|
|
375
|
-
|
|
376
|
-
start_time = time.time()
|
|
377
|
-
while timeout is None or (time.time() - start_time) < timeout:
|
|
378
|
-
task_response = self._client._get_task(self.id())
|
|
379
|
-
self._task_response = task_response
|
|
380
|
-
if self._task_response.live_url:
|
|
381
|
-
return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
|
|
382
|
-
time.sleep(1)
|
|
383
|
-
|
|
384
|
-
raise TimeoutError(f"Live URL not available for task {self.id()}.")
|
|
385
|
-
|
|
386
|
-
def recording_url(self, timeout: int | None = None) -> str:
|
|
387
|
-
"""Returns the recording URL for the task."""
|
|
388
|
-
if self._task_response and self._task_response.recording_url is not None:
|
|
389
|
-
return self._task_response.recording_url
|
|
390
|
-
|
|
391
|
-
start_time = time.time()
|
|
392
|
-
while timeout is None or (time.time() - start_time) < timeout:
|
|
393
|
-
task_response = self._client._get_task(self.id())
|
|
394
|
-
self._task_response = task_response
|
|
395
|
-
if task_response.recording_url is not None:
|
|
396
|
-
return task_response.recording_url
|
|
397
|
-
time.sleep(1)
|
|
398
|
-
raise TimeoutError(f"Recording URL not available for task {self.id()}.")
|
|
476
|
+
extensions: list[Extension] = Field(description="The list of extensions.")
|
|
399
477
|
|
|
400
478
|
|
|
401
|
-
|
|
402
|
-
"""A synchronous client for the API."""
|
|
403
|
-
|
|
404
|
-
def __init__(self, api_key: str | None = None, base_url: str = BASE_URL, api_version: str = "v1"):
|
|
405
|
-
"""Initializes the synchronous client."""
|
|
406
|
-
super().__init__(api_key, base_url, api_version)
|
|
407
|
-
self._session = requests.Session()
|
|
408
|
-
self._session.headers.update(self.headers)
|
|
409
|
-
|
|
410
|
-
def __enter__(self):
|
|
411
|
-
"""Enters the synchronous context manager."""
|
|
412
|
-
return self
|
|
413
|
-
|
|
414
|
-
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
|
|
415
|
-
"""Exits the synchronous context manager."""
|
|
416
|
-
self.close()
|
|
417
|
-
|
|
418
|
-
def close(self):
|
|
419
|
-
"""Close the session."""
|
|
420
|
-
if hasattr(self, "_session"):
|
|
421
|
-
self._session.close()
|
|
422
|
-
|
|
423
|
-
def _submit_task(self, payload: TaskRequest) -> TaskResponse:
|
|
424
|
-
"""Submits a task to be run."""
|
|
425
|
-
try:
|
|
426
|
-
response = self._session.post(f"{self.base_url}/task", json=payload.model_dump(exclude_none=True))
|
|
427
|
-
data = self._handle_response(response)
|
|
428
|
-
return TaskResponse(**data["r"])
|
|
429
|
-
except requests.exceptions.RequestException as e:
|
|
430
|
-
logger.error(f"Request failed: {e}")
|
|
431
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
432
|
-
|
|
433
|
-
def _get_task(self, task_id: str) -> TaskResponse:
|
|
434
|
-
"""Retrieves the status and result of a task."""
|
|
435
|
-
if not task_id:
|
|
436
|
-
raise ValueError("Task ID cannot be empty.")
|
|
437
|
-
|
|
438
|
-
try:
|
|
439
|
-
response = self._session.get(f"{self.base_url}/task/{task_id}")
|
|
440
|
-
data = self._handle_response(response)
|
|
441
|
-
return TaskResponse(**data["r"])
|
|
442
|
-
except requests.exceptions.RequestException as e:
|
|
443
|
-
logger.error(f"Request failed: {e}")
|
|
444
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
445
|
-
|
|
446
|
-
def _delete_task(self, task_id: str):
|
|
447
|
-
"""Deletes a task."""
|
|
448
|
-
if not task_id:
|
|
449
|
-
raise ValueError("Task ID cannot be empty.")
|
|
450
|
-
|
|
451
|
-
try:
|
|
452
|
-
response = self._session.delete(f"{self.base_url}/task/{task_id}")
|
|
453
|
-
self._handle_response(response)
|
|
454
|
-
except requests.exceptions.RequestException as e:
|
|
455
|
-
logger.error(f"Request failed: {e}")
|
|
456
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
457
|
-
|
|
458
|
-
def run(
|
|
459
|
-
self,
|
|
460
|
-
task: str,
|
|
461
|
-
response_model: dict[str, Any] | Type[BaseModel] | None = None,
|
|
462
|
-
url: str | None = None,
|
|
463
|
-
metadata: dict[str, str | int | float | bool] | None = None,
|
|
464
|
-
files: list[str] | None = None,
|
|
465
|
-
agent: Literal["smooth"] = "smooth",
|
|
466
|
-
max_steps: int = 32,
|
|
467
|
-
device: Literal["desktop", "mobile"] = "mobile",
|
|
468
|
-
allowed_urls: list[str] | None = None,
|
|
469
|
-
enable_recording: bool = False,
|
|
470
|
-
session_id: str | None = None,
|
|
471
|
-
profile_id: str | None = None,
|
|
472
|
-
profile_read_only: bool = False,
|
|
473
|
-
stealth_mode: bool = False,
|
|
474
|
-
proxy_server: str | None = None,
|
|
475
|
-
proxy_username: str | None = None,
|
|
476
|
-
proxy_password: str | None = None,
|
|
477
|
-
experimental_features: dict[str, Any] | None = None,
|
|
478
|
-
) -> TaskHandle:
|
|
479
|
-
"""Runs a task and returns a handle to the task.
|
|
480
|
-
|
|
481
|
-
This method submits a task and returns a `TaskHandle` object
|
|
482
|
-
that can be used to get the result of the task.
|
|
479
|
+
# --- Exception Handling ---
|
|
483
480
|
|
|
484
|
-
Args:
|
|
485
|
-
task: The task to run.
|
|
486
|
-
response_model: If provided, the schema describing the desired output structure.
|
|
487
|
-
url: The starting URL for the task. If not provided, the agent will infer it from the task.
|
|
488
|
-
metadata: A dictionary containing variables or parameters that will be passed to the agent.
|
|
489
|
-
files: A list of file ids to pass to the agent.
|
|
490
|
-
agent: The agent to use for the task.
|
|
491
|
-
max_steps: Maximum number of steps the agent can take (max 64).
|
|
492
|
-
device: Device type for the task. Default is mobile.
|
|
493
|
-
allowed_urls: List of allowed URL patterns using wildcard syntax (e.g., https://*example.com/*).
|
|
494
|
-
If None, all URLs are allowed.
|
|
495
|
-
enable_recording: Enable video recording of the task execution.
|
|
496
|
-
session_id: (Deprecated, now `profile_id`) Browser session ID to use.
|
|
497
|
-
profile_id: Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials.
|
|
498
|
-
profile_read_only: If true, the profile specified by `profile_id` will be loaded in read-only mode.
|
|
499
|
-
stealth_mode: Run the browser in stealth mode.
|
|
500
|
-
proxy_server: Proxy server url to route browser traffic through.
|
|
501
|
-
proxy_username: Proxy server username.
|
|
502
|
-
proxy_password: Proxy server password.
|
|
503
|
-
experimental_features: Experimental features to enable for the task.
|
|
504
481
|
|
|
505
|
-
|
|
506
|
-
|
|
482
|
+
class ApiError(Exception):
|
|
483
|
+
"""Custom exception for API errors."""
|
|
507
484
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
files=files,
|
|
517
|
-
agent=agent,
|
|
518
|
-
max_steps=max_steps,
|
|
519
|
-
device=device,
|
|
520
|
-
allowed_urls=allowed_urls,
|
|
521
|
-
enable_recording=enable_recording,
|
|
522
|
-
profile_id=profile_id or session_id,
|
|
523
|
-
profile_read_only=profile_read_only,
|
|
524
|
-
stealth_mode=stealth_mode,
|
|
525
|
-
proxy_server=proxy_server,
|
|
526
|
-
proxy_username=proxy_username,
|
|
527
|
-
proxy_password=proxy_password,
|
|
528
|
-
experimental_features=experimental_features,
|
|
529
|
-
)
|
|
530
|
-
initial_response = self._submit_task(payload)
|
|
531
|
-
|
|
532
|
-
return TaskHandle(initial_response.id, self)
|
|
533
|
-
|
|
534
|
-
def open_session(
|
|
535
|
-
self, profile_id: str | None = None, session_id: str | None = None, live_view: bool = True
|
|
536
|
-
) -> BrowserSessionHandle:
|
|
537
|
-
"""Opens an interactive browser instance to interact with a specific browser profile.
|
|
485
|
+
def __init__(
|
|
486
|
+
self, status_code: int, detail: str, response_data: dict[str, Any] | None = None
|
|
487
|
+
):
|
|
488
|
+
"""Initializes the API error."""
|
|
489
|
+
self.status_code = status_code
|
|
490
|
+
self.detail = detail
|
|
491
|
+
self.response_data = response_data
|
|
492
|
+
super().__init__(f"API Error {status_code}: {detail}")
|
|
538
493
|
|
|
539
|
-
Args:
|
|
540
|
-
profile_id: The profile ID to use for the session. If None, a new profile will be created.
|
|
541
|
-
session_id: (Deprecated, now `profile_id`) The session ID to associate with the browser.
|
|
542
|
-
live_view: Whether to enable live view for the session.
|
|
543
494
|
|
|
544
|
-
|
|
545
|
-
|
|
495
|
+
class TimeoutError(Exception):
|
|
496
|
+
"""Custom exception for task timeouts."""
|
|
546
497
|
|
|
547
|
-
|
|
548
|
-
ApiException: If the API request fails.
|
|
549
|
-
"""
|
|
550
|
-
try:
|
|
551
|
-
response = self._session.post(
|
|
552
|
-
f"{self.base_url}/browser/session",
|
|
553
|
-
json=BrowserSessionRequest(profile_id=profile_id or session_id, live_view=live_view).model_dump(exclude_none=True),
|
|
554
|
-
)
|
|
555
|
-
data = self._handle_response(response)
|
|
556
|
-
return BrowserSessionHandle(browser_session=BrowserSessionResponse(**data["r"]))
|
|
557
|
-
except requests.exceptions.RequestException as e:
|
|
558
|
-
logger.error(f"Request failed: {e}")
|
|
559
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
560
|
-
|
|
561
|
-
def close_session(self, live_id: str):
|
|
562
|
-
"""Closes a browser session."""
|
|
563
|
-
try:
|
|
564
|
-
response = self._session.delete(f"{self.base_url}/browser/session/{live_id}")
|
|
565
|
-
self._handle_response(response)
|
|
566
|
-
except requests.exceptions.RequestException as e:
|
|
567
|
-
logger.error(f"Request failed: {e}")
|
|
568
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
569
|
-
|
|
570
|
-
def list_profiles(self):
|
|
571
|
-
"""Lists all browser profiles for the user.
|
|
498
|
+
pass
|
|
572
499
|
|
|
573
|
-
Returns:
|
|
574
|
-
A list of existing browser profiles.
|
|
575
500
|
|
|
576
|
-
|
|
577
|
-
ApiException: If the API request fails.
|
|
578
|
-
"""
|
|
579
|
-
try:
|
|
580
|
-
response = self._session.get(f"{self.base_url}/browser/session")
|
|
581
|
-
data = self._handle_response(response)
|
|
582
|
-
return BrowserProfilesResponse(**data["r"])
|
|
583
|
-
except requests.exceptions.RequestException as e:
|
|
584
|
-
logger.error(f"Request failed: {e}")
|
|
585
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
586
|
-
|
|
587
|
-
@deprecated("list_sessions is deprecated, use list_profiles instead")
|
|
588
|
-
def list_sessions(self):
|
|
589
|
-
"""Lists all browser profiles for the user."""
|
|
590
|
-
return self.list_profiles()
|
|
591
|
-
|
|
592
|
-
def delete_profile(self, profile_id: str):
|
|
593
|
-
"""Delete a browser profile."""
|
|
594
|
-
try:
|
|
595
|
-
response = self._session.delete(f"{self.base_url}/browser/session/{profile_id}")
|
|
596
|
-
self._handle_response(response)
|
|
597
|
-
except requests.exceptions.RequestException as e:
|
|
598
|
-
logger.error(f"Request failed: {e}")
|
|
599
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
600
|
-
|
|
601
|
-
@deprecated("delete_session is deprecated, use delete_profile instead")
|
|
602
|
-
def delete_session(self, session_id: str):
|
|
603
|
-
"""Delete a browser profile."""
|
|
604
|
-
self.delete_profile(session_id)
|
|
605
|
-
|
|
606
|
-
def upload_file(self, file: io.IOBase, name: str | None = None, purpose: str | None = None) -> UploadFileResponse:
|
|
607
|
-
"""Upload a file and return the file ID.
|
|
501
|
+
# --- Base Client ---
|
|
608
502
|
|
|
609
|
-
Args:
|
|
610
|
-
file: File object to be uploaded.
|
|
611
|
-
name: Optional custom name for the file. If not provided, the original file name will be used.
|
|
612
|
-
purpose: Optional short description of the file to describe its purpose (i.e., 'the bank statement pdf').
|
|
613
503
|
|
|
614
|
-
|
|
615
|
-
|
|
504
|
+
class BaseClient:
|
|
505
|
+
"""Base client for handling common API interactions."""
|
|
506
|
+
|
|
507
|
+
def __init__(
|
|
508
|
+
self,
|
|
509
|
+
api_key: str | None = None,
|
|
510
|
+
base_url: str = BASE_URL,
|
|
511
|
+
api_version: str = "v1",
|
|
512
|
+
):
|
|
513
|
+
"""Initializes the base client."""
|
|
514
|
+
# Try to get API key from environment if not provided
|
|
515
|
+
if not api_key:
|
|
516
|
+
api_key = os.getenv("CIRCLEMIND_API_KEY")
|
|
517
|
+
|
|
518
|
+
if not api_key:
|
|
519
|
+
raise ValueError(
|
|
520
|
+
"API key is required. Provide it directly or set CIRCLEMIND_API_KEY environment variable."
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
if not base_url:
|
|
524
|
+
raise ValueError("Base URL cannot be empty.")
|
|
525
|
+
|
|
526
|
+
self.api_key = api_key
|
|
527
|
+
self.base_url = f"{base_url.rstrip('/')}/{api_version}"
|
|
528
|
+
self.headers = {
|
|
529
|
+
"apikey": self.api_key,
|
|
530
|
+
"User-Agent": "smooth-python-sdk/0.2.5",
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
def _handle_response(
|
|
534
|
+
self, response: requests.Response | httpx.Response
|
|
535
|
+
) -> dict[str, Any]:
|
|
536
|
+
"""Handles HTTP responses and raises exceptions for errors."""
|
|
537
|
+
if 200 <= response.status_code < 300:
|
|
538
|
+
try:
|
|
539
|
+
return response.json()
|
|
540
|
+
except ValueError as e:
|
|
541
|
+
logger.error(f"Failed to parse JSON response: {e}")
|
|
542
|
+
raise ApiError(
|
|
543
|
+
status_code=response.status_code,
|
|
544
|
+
detail="Invalid JSON response from server",
|
|
545
|
+
) from None
|
|
546
|
+
|
|
547
|
+
# Handle error responses
|
|
548
|
+
error_data = None
|
|
549
|
+
try:
|
|
550
|
+
error_data = response.json()
|
|
551
|
+
detail = error_data.get("detail", response.text)
|
|
552
|
+
except ValueError:
|
|
553
|
+
detail = response.text or f"HTTP {response.status_code} error"
|
|
554
|
+
|
|
555
|
+
logger.error(f"API error: {response.status_code} - {detail}")
|
|
556
|
+
raise ApiError(
|
|
557
|
+
status_code=response.status_code, detail=detail, response_data=error_data
|
|
558
|
+
)
|
|
616
559
|
|
|
617
|
-
Raises:
|
|
618
|
-
ValueError: If the file doesn't exist or can't be read.
|
|
619
|
-
ApiError: If the API request fails.
|
|
620
|
-
"""
|
|
621
|
-
try:
|
|
622
|
-
name = name or getattr(file, "name", None)
|
|
623
|
-
if name is None:
|
|
624
|
-
raise ValueError("File name must be provided or the file object must have a 'name' attribute.")
|
|
625
|
-
|
|
626
|
-
if purpose:
|
|
627
|
-
data = {"file_purpose": purpose}
|
|
628
|
-
else:
|
|
629
|
-
data = None
|
|
630
|
-
|
|
631
|
-
files = {"file": (Path(name).name, file)}
|
|
632
|
-
response = self._session.post(f"{self.base_url}/file", files=files, data=data)
|
|
633
|
-
data = self._handle_response(response)
|
|
634
|
-
return UploadFileResponse(**data["r"])
|
|
635
|
-
except requests.exceptions.RequestException as e:
|
|
636
|
-
logger.error(f"Request failed: {e}")
|
|
637
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
638
|
-
|
|
639
|
-
def delete_file(self, file_id: str):
|
|
640
|
-
"""Delete a file by its ID."""
|
|
641
|
-
try:
|
|
642
|
-
response = self._session.delete(f"{self.base_url}/file/{file_id}")
|
|
643
|
-
self._handle_response(response)
|
|
644
|
-
except requests.exceptions.RequestException as e:
|
|
645
|
-
logger.error(f"Request failed: {e}")
|
|
646
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
647
560
|
|
|
561
|
+
# --- Synchronous Client ---
|
|
648
562
|
|
|
649
|
-
# --- Asynchronous Client ---
|
|
650
563
|
|
|
564
|
+
class BrowserSessionHandle(BaseModel):
|
|
565
|
+
"""Browser session handle model."""
|
|
651
566
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
def __init__(self, task_id: str, client: "SmoothAsyncClient"):
|
|
656
|
-
"""Initializes the asynchronous task handle."""
|
|
657
|
-
self._client = client
|
|
658
|
-
self._task_response: TaskResponse | None = None
|
|
659
|
-
|
|
660
|
-
self._id = task_id
|
|
661
|
-
|
|
662
|
-
def id(self):
|
|
663
|
-
"""Returns the task ID."""
|
|
664
|
-
return self._id
|
|
665
|
-
|
|
666
|
-
async def stop(self):
|
|
667
|
-
"""Stops the task."""
|
|
668
|
-
await self._client._delete_task(self._id)
|
|
669
|
-
|
|
670
|
-
async def result(self, timeout: int | None = None, poll_interval: float = 1) -> TaskResponse:
|
|
671
|
-
"""Waits for the task to complete and returns the result."""
|
|
672
|
-
if self._task_response and self._task_response.status not in ["running", "waiting"]:
|
|
673
|
-
return self._task_response
|
|
674
|
-
|
|
675
|
-
if timeout is not None and timeout < 1:
|
|
676
|
-
raise ValueError("Timeout must be at least 1 second.")
|
|
677
|
-
if poll_interval < 0.1:
|
|
678
|
-
raise ValueError("Poll interval must be at least 100 milliseconds.")
|
|
679
|
-
|
|
680
|
-
start_time = time.time()
|
|
681
|
-
while timeout is None or (time.time() - start_time) < timeout:
|
|
682
|
-
task_response = await self._client._get_task(self.id())
|
|
683
|
-
self._task_response = task_response
|
|
684
|
-
if task_response.status not in ["running", "waiting"]:
|
|
685
|
-
return task_response
|
|
686
|
-
await asyncio.sleep(poll_interval)
|
|
687
|
-
raise TimeoutError(f"Task {self.id()} did not complete within {timeout} seconds.")
|
|
688
|
-
|
|
689
|
-
async def live_url(self, interactive: bool = False, embed: bool = False, timeout: int | None = None):
|
|
690
|
-
"""Returns the live URL for the task."""
|
|
691
|
-
if self._task_response and self._task_response.live_url:
|
|
692
|
-
return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
|
|
693
|
-
|
|
694
|
-
start_time = time.time()
|
|
695
|
-
while timeout is None or (time.time() - start_time) < timeout:
|
|
696
|
-
task_response = await self._client._get_task(self.id())
|
|
697
|
-
self._task_response = task_response
|
|
698
|
-
if self._task_response.live_url:
|
|
699
|
-
return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
|
|
700
|
-
await asyncio.sleep(1)
|
|
701
|
-
|
|
702
|
-
raise TimeoutError(f"Live URL not available for task {self.id()}.")
|
|
703
|
-
|
|
704
|
-
async def recording_url(self, timeout: int | None = None) -> str:
|
|
705
|
-
"""Returns the recording URL for the task."""
|
|
706
|
-
if self._task_response and self._task_response.recording_url is not None:
|
|
707
|
-
return self._task_response.recording_url
|
|
708
|
-
|
|
709
|
-
start_time = time.time()
|
|
710
|
-
while timeout is None or (time.time() - start_time) < timeout:
|
|
711
|
-
task_response = await self._client._get_task(self.id())
|
|
712
|
-
self._task_response = task_response
|
|
713
|
-
if task_response.recording_url is not None:
|
|
714
|
-
return task_response.recording_url
|
|
715
|
-
await asyncio.sleep(1)
|
|
716
|
-
|
|
717
|
-
raise TimeoutError(f"Recording URL not available for task {self.id()}.")
|
|
567
|
+
browser_session: BrowserSessionResponse = Field(
|
|
568
|
+
description="The browser session associated with this handle."
|
|
569
|
+
)
|
|
718
570
|
|
|
571
|
+
@deprecated("session_id is deprecated, use profile_id instead")
|
|
572
|
+
def session_id(self):
|
|
573
|
+
"""Returns the session ID for the browser session."""
|
|
574
|
+
return self.profile_id()
|
|
719
575
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
def __init__(self, api_key: str | None = None, base_url: str = BASE_URL, api_version: str = "v1", timeout: int = 30):
|
|
724
|
-
"""Initializes the asynchronous client."""
|
|
725
|
-
super().__init__(api_key, base_url, api_version)
|
|
726
|
-
self._client = httpx.AsyncClient(headers=self.headers, timeout=timeout)
|
|
727
|
-
|
|
728
|
-
async def __aenter__(self):
|
|
729
|
-
"""Enters the asynchronous context manager."""
|
|
730
|
-
return self
|
|
731
|
-
|
|
732
|
-
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
|
|
733
|
-
"""Exits the asynchronous context manager."""
|
|
734
|
-
await self.close()
|
|
735
|
-
|
|
736
|
-
async def _submit_task(self, payload: TaskRequest) -> TaskResponse:
|
|
737
|
-
"""Submits a task to be run asynchronously."""
|
|
738
|
-
try:
|
|
739
|
-
response = await self._client.post(f"{self.base_url}/task", json=payload.model_dump(exclude_none=True))
|
|
740
|
-
data = self._handle_response(response)
|
|
741
|
-
return TaskResponse(**data["r"])
|
|
742
|
-
except httpx.RequestError as e:
|
|
743
|
-
logger.error(f"Request failed: {e}")
|
|
744
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
745
|
-
|
|
746
|
-
async def _get_task(self, task_id: str) -> TaskResponse:
|
|
747
|
-
"""Retrieves the status and result of a task asynchronously."""
|
|
748
|
-
if not task_id:
|
|
749
|
-
raise ValueError("Task ID cannot be empty.")
|
|
750
|
-
|
|
751
|
-
try:
|
|
752
|
-
response = await self._client.get(f"{self.base_url}/task/{task_id}")
|
|
753
|
-
data = self._handle_response(response)
|
|
754
|
-
return TaskResponse(**data["r"])
|
|
755
|
-
except httpx.RequestError as e:
|
|
756
|
-
logger.error(f"Request failed: {e}")
|
|
757
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
758
|
-
|
|
759
|
-
async def _delete_task(self, task_id: str):
|
|
760
|
-
"""Deletes a task asynchronously."""
|
|
761
|
-
if not task_id:
|
|
762
|
-
raise ValueError("Task ID cannot be empty.")
|
|
763
|
-
|
|
764
|
-
try:
|
|
765
|
-
response = await self._client.delete(f"{self.base_url}/task/{task_id}")
|
|
766
|
-
self._handle_response(response)
|
|
767
|
-
except httpx.RequestError as e:
|
|
768
|
-
logger.error(f"Request failed: {e}")
|
|
769
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
770
|
-
|
|
771
|
-
async def run(
|
|
772
|
-
self,
|
|
773
|
-
task: str,
|
|
774
|
-
response_model: dict[str, Any] | Type[BaseModel] | None = None,
|
|
775
|
-
url: str | None = None,
|
|
776
|
-
metadata: dict[str, str | int | float | bool] | None = None,
|
|
777
|
-
files: list[str] | None = None,
|
|
778
|
-
agent: Literal["smooth"] = "smooth",
|
|
779
|
-
max_steps: int = 32,
|
|
780
|
-
device: Literal["desktop", "mobile"] = "mobile",
|
|
781
|
-
allowed_urls: list[str] | None = None,
|
|
782
|
-
enable_recording: bool = False,
|
|
783
|
-
session_id: str | None = None,
|
|
784
|
-
profile_id: str | None = None,
|
|
785
|
-
profile_read_only: bool = False,
|
|
786
|
-
stealth_mode: bool = False,
|
|
787
|
-
proxy_server: str | None = None,
|
|
788
|
-
proxy_username: str | None = None,
|
|
789
|
-
proxy_password: str | None = None,
|
|
790
|
-
experimental_features: dict[str, Any] | None = None,
|
|
791
|
-
) -> AsyncTaskHandle:
|
|
792
|
-
"""Runs a task and returns a handle to the task asynchronously.
|
|
793
|
-
|
|
794
|
-
This method submits a task and returns an `AsyncTaskHandle` object
|
|
795
|
-
that can be used to get the result of the task.
|
|
576
|
+
def profile_id(self):
|
|
577
|
+
"""Returns the profile ID for the browser session."""
|
|
578
|
+
return self.browser_session.profile_id
|
|
796
579
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
max_steps: Maximum number of steps the agent can take (max 64).
|
|
805
|
-
device: Device type for the task. Default is mobile.
|
|
806
|
-
allowed_urls: List of allowed URL patterns using wildcard syntax (e.g., https://*example.com/*).
|
|
807
|
-
If None, all URLs are allowed.
|
|
808
|
-
enable_recording: Enable video recording of the task execution.
|
|
809
|
-
session_id: (Deprecated, now `profile_id`) Browser session ID to use.
|
|
810
|
-
profile_id: Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials.
|
|
811
|
-
profile_read_only: If true, the profile specified by `profile_id` will be loaded in read-only mode.
|
|
812
|
-
stealth_mode: Run the browser in stealth mode.
|
|
813
|
-
proxy_server: Proxy server url to route browser traffic through.
|
|
814
|
-
proxy_username: Proxy server username.
|
|
815
|
-
proxy_password: Proxy server password.
|
|
816
|
-
experimental_features: Experimental features to enable for the task.
|
|
580
|
+
def live_url(self, interactive: bool = True, embed: bool = False):
|
|
581
|
+
"""Returns the live URL for the browser session."""
|
|
582
|
+
if self.browser_session.live_url:
|
|
583
|
+
return _encode_url(
|
|
584
|
+
self.browser_session.live_url, interactive=interactive, embed=embed
|
|
585
|
+
)
|
|
586
|
+
return None
|
|
817
587
|
|
|
818
|
-
|
|
819
|
-
|
|
588
|
+
def live_id(self):
|
|
589
|
+
"""Returns the live ID for the browser session."""
|
|
590
|
+
return self.browser_session.live_id
|
|
820
591
|
|
|
821
|
-
Raises:
|
|
822
|
-
ApiException: If the API request fails.
|
|
823
|
-
"""
|
|
824
|
-
payload = TaskRequest(
|
|
825
|
-
task=task,
|
|
826
|
-
response_model=response_model if isinstance(response_model, dict | None) else response_model.model_json_schema(),
|
|
827
|
-
url=url,
|
|
828
|
-
metadata=metadata,
|
|
829
|
-
files=files,
|
|
830
|
-
agent=agent,
|
|
831
|
-
max_steps=max_steps,
|
|
832
|
-
device=device,
|
|
833
|
-
allowed_urls=allowed_urls,
|
|
834
|
-
enable_recording=enable_recording,
|
|
835
|
-
profile_id=profile_id or session_id,
|
|
836
|
-
profile_read_only=profile_read_only,
|
|
837
|
-
stealth_mode=stealth_mode,
|
|
838
|
-
proxy_server=proxy_server,
|
|
839
|
-
proxy_username=proxy_username,
|
|
840
|
-
proxy_password=proxy_password,
|
|
841
|
-
experimental_features=experimental_features,
|
|
842
|
-
)
|
|
843
|
-
|
|
844
|
-
initial_response = await self._submit_task(payload)
|
|
845
|
-
return AsyncTaskHandle(initial_response.id, self)
|
|
846
|
-
|
|
847
|
-
async def open_session(
|
|
848
|
-
self, profile_id: str | None = None, session_id: str | None = None, live_view: bool = True
|
|
849
|
-
) -> BrowserSessionHandle:
|
|
850
|
-
"""Opens an interactive browser instance asynchronously.
|
|
851
592
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
593
|
+
class TaskHandle:
|
|
594
|
+
"""A handle to a running task."""
|
|
595
|
+
|
|
596
|
+
def __init__(self, task_id: str, client: "SmoothClient"):
|
|
597
|
+
"""Initializes the task handle."""
|
|
598
|
+
self._client = client
|
|
599
|
+
self._task_response: TaskResponse | None = None
|
|
600
|
+
|
|
601
|
+
self._id = task_id
|
|
602
|
+
|
|
603
|
+
def id(self):
|
|
604
|
+
"""Returns the task ID."""
|
|
605
|
+
return self._id
|
|
606
|
+
|
|
607
|
+
def stop(self):
|
|
608
|
+
"""Stops the task."""
|
|
609
|
+
self._client._delete_task(self._id)
|
|
610
|
+
|
|
611
|
+
def result(
|
|
612
|
+
self, timeout: int | None = None, poll_interval: float = 1
|
|
613
|
+
) -> TaskResponse:
|
|
614
|
+
"""Waits for the task to complete and returns the result."""
|
|
615
|
+
if self._task_response and self._task_response.status not in [
|
|
616
|
+
"running",
|
|
617
|
+
"waiting",
|
|
618
|
+
]:
|
|
619
|
+
return self._task_response
|
|
620
|
+
|
|
621
|
+
if timeout is not None and timeout < 1:
|
|
622
|
+
raise ValueError("Timeout must be at least 1 second.")
|
|
623
|
+
if poll_interval < 0.1:
|
|
624
|
+
raise ValueError("Poll interval must be at least 100 milliseconds.")
|
|
625
|
+
|
|
626
|
+
start_time = time.time()
|
|
627
|
+
while timeout is None or (time.time() - start_time) < timeout:
|
|
628
|
+
task_response = self._client._get_task(self.id())
|
|
629
|
+
self._task_response = task_response
|
|
630
|
+
if task_response.status not in ["running", "waiting"]:
|
|
631
|
+
return task_response
|
|
632
|
+
time.sleep(poll_interval)
|
|
633
|
+
raise TimeoutError(
|
|
634
|
+
f"Task {self.id()} did not complete within {timeout} seconds."
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
def live_url(
|
|
638
|
+
self, interactive: bool = False, embed: bool = False, timeout: int | None = None
|
|
639
|
+
):
|
|
640
|
+
"""Returns the live URL for the task."""
|
|
641
|
+
if self._task_response and self._task_response.live_url:
|
|
642
|
+
return _encode_url(
|
|
643
|
+
self._task_response.live_url, interactive=interactive, embed=embed
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
start_time = time.time()
|
|
647
|
+
while timeout is None or (time.time() - start_time) < timeout:
|
|
648
|
+
task_response = self._client._get_task(self.id())
|
|
649
|
+
self._task_response = task_response
|
|
650
|
+
if self._task_response.live_url:
|
|
651
|
+
return _encode_url(
|
|
652
|
+
self._task_response.live_url, interactive=interactive, embed=embed
|
|
653
|
+
)
|
|
654
|
+
time.sleep(1)
|
|
655
|
+
|
|
656
|
+
raise TimeoutError(f"Live URL not available for task {self.id()}.")
|
|
657
|
+
|
|
658
|
+
def recording_url(self, timeout: int | None = None) -> str:
|
|
659
|
+
"""Returns the recording URL for the task."""
|
|
660
|
+
if self._task_response and self._task_response.recording_url is not None:
|
|
661
|
+
return self._task_response.recording_url
|
|
662
|
+
|
|
663
|
+
start_time = time.time()
|
|
664
|
+
while timeout is None or (time.time() - start_time) < timeout:
|
|
665
|
+
task_response = self._client._get_task(self.id())
|
|
666
|
+
self._task_response = task_response
|
|
667
|
+
if task_response.recording_url is not None:
|
|
668
|
+
if not task_response.recording_url:
|
|
669
|
+
raise ApiError(
|
|
670
|
+
status_code=404,
|
|
671
|
+
detail=(
|
|
672
|
+
f"Recording URL not available for task {self.id()}."
|
|
673
|
+
" Set `enable_recording=True` when creating the task to enable it."
|
|
674
|
+
),
|
|
675
|
+
)
|
|
676
|
+
return task_response.recording_url
|
|
677
|
+
time.sleep(1)
|
|
678
|
+
raise TimeoutError(f"Recording URL not available for task {self.id()}.")
|
|
679
|
+
|
|
680
|
+
def downloads_url(self, timeout: int | None = None) -> str:
|
|
681
|
+
"""Returns the downloads URL for the task."""
|
|
682
|
+
if self._task_response and self._task_response.downloads_url is not None:
|
|
683
|
+
return self._task_response.downloads_url
|
|
684
|
+
|
|
685
|
+
start_time = time.time()
|
|
686
|
+
while timeout is None or (time.time() - start_time) < timeout:
|
|
687
|
+
task_response = self._client._get_task(
|
|
688
|
+
self.id(), query_params={"downloads": "true"}
|
|
689
|
+
)
|
|
690
|
+
self._task_response = task_response
|
|
691
|
+
if task_response.downloads_url is not None:
|
|
692
|
+
if not task_response.downloads_url:
|
|
693
|
+
raise ApiError(
|
|
694
|
+
status_code=404,
|
|
695
|
+
detail=(
|
|
696
|
+
f"Downloads URL not available for task {self.id()}."
|
|
697
|
+
" Make sure the task downloaded files during its execution."
|
|
698
|
+
),
|
|
699
|
+
)
|
|
700
|
+
return task_response.downloads_url
|
|
701
|
+
time.sleep(1)
|
|
702
|
+
raise TimeoutError(f"Downloads URL not available for task {self.id()}.")
|
|
856
703
|
|
|
857
|
-
Returns:
|
|
858
|
-
The browser session details, including the live URL.
|
|
859
704
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
705
|
+
class SmoothClient(BaseClient):
|
|
706
|
+
"""A synchronous client for the API."""
|
|
707
|
+
|
|
708
|
+
def __init__(
|
|
709
|
+
self,
|
|
710
|
+
api_key: str | None = None,
|
|
711
|
+
base_url: str = BASE_URL,
|
|
712
|
+
api_version: str = "v1",
|
|
713
|
+
):
|
|
714
|
+
"""Initializes the synchronous client."""
|
|
715
|
+
super().__init__(api_key, base_url, api_version)
|
|
716
|
+
self._session = requests.Session()
|
|
717
|
+
self._session.headers.update(self.headers)
|
|
718
|
+
|
|
719
|
+
def __enter__(self):
|
|
720
|
+
"""Enters the synchronous context manager."""
|
|
721
|
+
return self
|
|
722
|
+
|
|
723
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
|
|
724
|
+
"""Exits the synchronous context manager."""
|
|
725
|
+
self.close()
|
|
726
|
+
|
|
727
|
+
def close(self):
|
|
728
|
+
"""Close the session."""
|
|
729
|
+
if hasattr(self, "_session"):
|
|
730
|
+
self._session.close()
|
|
731
|
+
|
|
732
|
+
def _submit_task(self, payload: TaskRequest) -> TaskResponse:
|
|
733
|
+
"""Submits a task to be run."""
|
|
734
|
+
try:
|
|
735
|
+
response = self._session.post(
|
|
736
|
+
f"{self.base_url}/task", json=payload.model_dump()
|
|
737
|
+
)
|
|
738
|
+
data = self._handle_response(response)
|
|
739
|
+
return TaskResponse(**data["r"])
|
|
740
|
+
except requests.exceptions.RequestException as e:
|
|
741
|
+
logger.error(f"Request failed: {e}")
|
|
742
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
743
|
+
|
|
744
|
+
def _get_task(
|
|
745
|
+
self, task_id: str, query_params: dict[str, Any] | None = None
|
|
746
|
+
) -> TaskResponse:
|
|
747
|
+
"""Retrieves the status and result of a task."""
|
|
748
|
+
if not task_id:
|
|
749
|
+
raise ValueError("Task ID cannot be empty.")
|
|
750
|
+
|
|
751
|
+
try:
|
|
752
|
+
url = f"{self.base_url}/task/{task_id}"
|
|
753
|
+
response = self._session.get(url, params=query_params)
|
|
754
|
+
data = self._handle_response(response)
|
|
755
|
+
return TaskResponse(**data["r"])
|
|
756
|
+
except requests.exceptions.RequestException as e:
|
|
757
|
+
logger.error(f"Request failed: {e}")
|
|
758
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
759
|
+
|
|
760
|
+
def _delete_task(self, task_id: str):
|
|
761
|
+
"""Deletes a task."""
|
|
762
|
+
if not task_id:
|
|
763
|
+
raise ValueError("Task ID cannot be empty.")
|
|
764
|
+
|
|
765
|
+
try:
|
|
766
|
+
response = self._session.delete(f"{self.base_url}/task/{task_id}")
|
|
767
|
+
self._handle_response(response)
|
|
768
|
+
except requests.exceptions.RequestException as e:
|
|
769
|
+
logger.error(f"Request failed: {e}")
|
|
770
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
771
|
+
|
|
772
|
+
def run(
|
|
773
|
+
self,
|
|
774
|
+
task: str,
|
|
775
|
+
response_model: dict[str, Any] | Type[BaseModel] | None = None,
|
|
776
|
+
url: str | None = None,
|
|
777
|
+
metadata: dict[str, str | int | float | bool] | None = None,
|
|
778
|
+
files: list[str] | None = None,
|
|
779
|
+
agent: Literal["smooth"] = "smooth",
|
|
780
|
+
max_steps: int = 32,
|
|
781
|
+
device: Literal["desktop", "mobile"] = "mobile",
|
|
782
|
+
allowed_urls: list[str] | None = None,
|
|
783
|
+
enable_recording: bool = True,
|
|
784
|
+
session_id: str | None = None,
|
|
785
|
+
profile_id: str | None = None,
|
|
786
|
+
profile_read_only: bool = False,
|
|
787
|
+
stealth_mode: bool = False,
|
|
788
|
+
proxy_server: str | None = None,
|
|
789
|
+
proxy_username: str | None = None,
|
|
790
|
+
proxy_password: str | None = None,
|
|
791
|
+
certificates: list[Certificate] | None = None,
|
|
792
|
+
use_adblock: bool | None = True,
|
|
793
|
+
additional_tools: dict[str, dict[str, Any] | None] | None = None,
|
|
794
|
+
experimental_features: dict[str, Any] | None = None,
|
|
795
|
+
extensions: list[str] | None = None,
|
|
796
|
+
) -> TaskHandle:
|
|
797
|
+
"""Runs a task and returns a handle to the task.
|
|
798
|
+
|
|
799
|
+
This method submits a task and returns a `TaskHandle` object
|
|
800
|
+
that can be used to get the result of the task.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
task: The task to run.
|
|
804
|
+
response_model: If provided, the schema describing the desired output structure.
|
|
805
|
+
url: The starting URL for the task. If not provided, the agent will infer it from the task.
|
|
806
|
+
metadata: A dictionary containing variables or parameters that will be passed to the agent.
|
|
807
|
+
files: A list of file ids to pass to the agent.
|
|
808
|
+
agent: The agent to use for the task.
|
|
809
|
+
max_steps: Maximum number of steps the agent can take (max 64).
|
|
810
|
+
device: Device type for the task. Default is mobile.
|
|
811
|
+
allowed_urls: List of allowed URL patterns using wildcard syntax (e.g., https://*example.com/*).
|
|
812
|
+
If None, all URLs are allowed.
|
|
813
|
+
enable_recording: Enable video recording of the task execution.
|
|
814
|
+
session_id: (Deprecated, now `profile_id`) Browser session ID to use.
|
|
815
|
+
profile_id: Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials.
|
|
816
|
+
profile_read_only: If true, the profile specified by `profile_id` will be loaded in read-only mode.
|
|
817
|
+
stealth_mode: Run the browser in stealth mode.
|
|
818
|
+
proxy_server: Proxy server address to route browser traffic through.
|
|
819
|
+
proxy_username: Proxy server username.
|
|
820
|
+
proxy_password: Proxy server password.
|
|
821
|
+
certificates: List of client certificates to use when accessing secure websites.
|
|
822
|
+
Each certificate is a dictionary with the following fields:
|
|
823
|
+
- `file` (required): p12 file object to be uploaded (e.g., open("cert.p12", "rb")).
|
|
824
|
+
- `password` (optional): Password to decrypt the certificate file, if password-protected.
|
|
825
|
+
use_adblock: Enable adblock for the browser session. Default is True.
|
|
826
|
+
additional_tools: Additional tools to enable for the task.
|
|
827
|
+
experimental_features: Experimental features to enable for the task.
|
|
828
|
+
|
|
829
|
+
Returns:
|
|
830
|
+
A handle to the running task.
|
|
831
|
+
|
|
832
|
+
Raises:
|
|
833
|
+
ApiException: If the API request fails.
|
|
834
|
+
"""
|
|
835
|
+
payload = TaskRequest(
|
|
836
|
+
task=task,
|
|
837
|
+
response_model=response_model
|
|
838
|
+
if isinstance(response_model, dict | None)
|
|
839
|
+
else response_model.model_json_schema(),
|
|
840
|
+
url=url,
|
|
841
|
+
metadata=metadata,
|
|
842
|
+
files=files,
|
|
843
|
+
agent=agent,
|
|
844
|
+
max_steps=max_steps,
|
|
845
|
+
device=device,
|
|
846
|
+
allowed_urls=allowed_urls,
|
|
847
|
+
enable_recording=enable_recording,
|
|
848
|
+
profile_id=profile_id or session_id,
|
|
849
|
+
profile_read_only=profile_read_only,
|
|
850
|
+
stealth_mode=stealth_mode,
|
|
851
|
+
proxy_server=proxy_server,
|
|
852
|
+
proxy_username=proxy_username,
|
|
853
|
+
proxy_password=proxy_password,
|
|
854
|
+
certificates=_process_certificates(certificates),
|
|
855
|
+
use_adblock=use_adblock,
|
|
856
|
+
additional_tools=additional_tools,
|
|
857
|
+
experimental_features=experimental_features,
|
|
858
|
+
extensions=extensions,
|
|
859
|
+
)
|
|
860
|
+
initial_response = self._submit_task(payload)
|
|
861
|
+
|
|
862
|
+
return TaskHandle(initial_response.id, self)
|
|
863
|
+
|
|
864
|
+
def open_session(
|
|
865
|
+
self,
|
|
866
|
+
profile_id: str | None = None,
|
|
867
|
+
session_id: str | None = None,
|
|
868
|
+
live_view: bool = True,
|
|
869
|
+
device: Literal["desktop", "mobile"] = "desktop",
|
|
870
|
+
url: str | None = None,
|
|
871
|
+
proxy_server: str | None = None,
|
|
872
|
+
proxy_username: str | None = None,
|
|
873
|
+
proxy_password: str | None = None,
|
|
874
|
+
) -> BrowserSessionHandle:
|
|
875
|
+
"""Opens an interactive browser instance to interact with a specific browser profile.
|
|
876
|
+
|
|
877
|
+
Args:
|
|
878
|
+
profile_id: The profile ID to use for the session. If None, a new profile will be created.
|
|
879
|
+
session_id: (Deprecated, now `profile_id`) The session ID to associate with the browser.
|
|
880
|
+
live_view: Whether to enable live view for the session.
|
|
881
|
+
device: The device type to use for the browser session.
|
|
882
|
+
url: The URL to open in the browser session.
|
|
883
|
+
proxy_server: Proxy server address to route browser traffic through.
|
|
884
|
+
proxy_username: Proxy server username.
|
|
885
|
+
proxy_password: Proxy server password.
|
|
886
|
+
|
|
887
|
+
Returns:
|
|
888
|
+
The browser session details, including the live URL.
|
|
889
|
+
|
|
890
|
+
Raises:
|
|
891
|
+
ApiException: If the API request fails.
|
|
892
|
+
"""
|
|
893
|
+
try:
|
|
894
|
+
response = self._session.post(
|
|
895
|
+
f"{self.base_url}/browser/session",
|
|
896
|
+
json=BrowserSessionRequest(
|
|
897
|
+
profile_id=profile_id or session_id,
|
|
898
|
+
live_view=live_view,
|
|
899
|
+
device=device,
|
|
900
|
+
url=url,
|
|
901
|
+
proxy_server=proxy_server,
|
|
902
|
+
proxy_username=proxy_username,
|
|
903
|
+
proxy_password=proxy_password,
|
|
904
|
+
).model_dump(),
|
|
905
|
+
)
|
|
906
|
+
data = self._handle_response(response)
|
|
907
|
+
return BrowserSessionHandle(
|
|
908
|
+
browser_session=BrowserSessionResponse(**data["r"])
|
|
909
|
+
)
|
|
910
|
+
except requests.exceptions.RequestException as e:
|
|
911
|
+
logger.error(f"Request failed: {e}")
|
|
912
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
913
|
+
|
|
914
|
+
def close_session(self, live_id: str):
|
|
915
|
+
"""Closes a browser session."""
|
|
916
|
+
try:
|
|
917
|
+
response = self._session.delete(
|
|
918
|
+
f"{self.base_url}/browser/session/{live_id}"
|
|
919
|
+
)
|
|
920
|
+
self._handle_response(response)
|
|
921
|
+
except requests.exceptions.RequestException as e:
|
|
922
|
+
logger.error(f"Request failed: {e}")
|
|
923
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
924
|
+
|
|
925
|
+
def list_profiles(self):
|
|
926
|
+
"""Lists all browser profiles for the user.
|
|
927
|
+
|
|
928
|
+
Returns:
|
|
929
|
+
A list of existing browser profiles.
|
|
930
|
+
|
|
931
|
+
Raises:
|
|
932
|
+
ApiException: If the API request fails.
|
|
933
|
+
"""
|
|
934
|
+
try:
|
|
935
|
+
response = self._session.get(f"{self.base_url}/browser/profile")
|
|
936
|
+
data = self._handle_response(response)
|
|
937
|
+
return BrowserProfilesResponse(**data["r"])
|
|
938
|
+
except requests.exceptions.RequestException as e:
|
|
939
|
+
logger.error(f"Request failed: {e}")
|
|
940
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
941
|
+
|
|
942
|
+
@deprecated("list_sessions is deprecated, use list_profiles instead")
|
|
943
|
+
def list_sessions(self):
|
|
944
|
+
"""Lists all browser profiles for the user."""
|
|
945
|
+
return self.list_profiles()
|
|
946
|
+
|
|
947
|
+
def delete_profile(self, profile_id: str):
|
|
948
|
+
"""Delete a browser profile."""
|
|
949
|
+
try:
|
|
950
|
+
response = self._session.delete(
|
|
951
|
+
f"{self.base_url}/browser/profile/{profile_id}"
|
|
952
|
+
)
|
|
953
|
+
self._handle_response(response)
|
|
954
|
+
except requests.exceptions.RequestException as e:
|
|
955
|
+
logger.error(f"Request failed: {e}")
|
|
956
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
957
|
+
|
|
958
|
+
@deprecated("delete_session is deprecated, use delete_profile instead")
|
|
959
|
+
def delete_session(self, session_id: str):
|
|
960
|
+
"""Delete a browser profile."""
|
|
961
|
+
self.delete_profile(session_id)
|
|
962
|
+
|
|
963
|
+
def upload_file(
|
|
964
|
+
self, file: io.IOBase, name: str | None = None, purpose: str | None = None
|
|
965
|
+
) -> UploadFileResponse:
|
|
966
|
+
"""Upload a file and return the file ID.
|
|
967
|
+
|
|
968
|
+
Args:
|
|
969
|
+
file: File object to be uploaded.
|
|
970
|
+
name: Optional custom name for the file. If not provided, the original file name will be used.
|
|
971
|
+
purpose: Optional short description of the file to describe its purpose (i.e., 'the bank statement pdf').
|
|
972
|
+
|
|
973
|
+
Returns:
|
|
974
|
+
The file ID assigned to the uploaded file.
|
|
975
|
+
|
|
976
|
+
Raises:
|
|
977
|
+
ValueError: If the file doesn't exist or can't be read.
|
|
978
|
+
ApiError: If the API request fails.
|
|
979
|
+
"""
|
|
980
|
+
try:
|
|
981
|
+
name = name or getattr(file, "name", None)
|
|
982
|
+
if name is None:
|
|
983
|
+
raise ValueError(
|
|
984
|
+
"File name must be provided or the file object must have a 'name' attribute."
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
if purpose:
|
|
988
|
+
data = {"file_purpose": purpose}
|
|
989
|
+
else:
|
|
990
|
+
data = None
|
|
991
|
+
|
|
992
|
+
files = {"file": (Path(name).name, file)}
|
|
993
|
+
response = self._session.post(
|
|
994
|
+
f"{self.base_url}/file", files=files, data=data
|
|
995
|
+
)
|
|
996
|
+
data = self._handle_response(response)
|
|
997
|
+
return UploadFileResponse(**data["r"])
|
|
998
|
+
except requests.exceptions.RequestException as e:
|
|
999
|
+
logger.error(f"Request failed: {e}")
|
|
1000
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1001
|
+
|
|
1002
|
+
def delete_file(self, file_id: str):
|
|
1003
|
+
"""Delete a file by its ID."""
|
|
1004
|
+
try:
|
|
1005
|
+
response = self._session.delete(f"{self.base_url}/file/{file_id}")
|
|
1006
|
+
self._handle_response(response)
|
|
1007
|
+
except requests.exceptions.RequestException as e:
|
|
1008
|
+
logger.error(f"Request failed: {e}")
|
|
1009
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1010
|
+
|
|
1011
|
+
def upload_extension(self, file: io.IOBase, name: str | None = None) -> UploadExtensionResponse:
|
|
1012
|
+
"""Upload an extension and return the extension ID."""
|
|
1013
|
+
try:
|
|
1014
|
+
name = name or getattr(file, "name", None)
|
|
1015
|
+
if name is None:
|
|
1016
|
+
raise ValueError(
|
|
1017
|
+
"Extension name must be provided or the extension object must have a 'name' attribute."
|
|
1018
|
+
)
|
|
1019
|
+
files = {"file": (Path(name).name, file)}
|
|
1020
|
+
response = self._session.post(f"{self.base_url}/browser/extension", files=files)
|
|
1021
|
+
data = self._handle_response(response)
|
|
1022
|
+
return UploadExtensionResponse(**data["r"])
|
|
1023
|
+
except requests.exceptions.RequestException as e:
|
|
1024
|
+
logger.error(f"Request failed: {e}")
|
|
1025
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1026
|
+
|
|
1027
|
+
def list_extensions(self) -> ListExtensionsResponse:
|
|
1028
|
+
"""List all extensions."""
|
|
1029
|
+
try:
|
|
1030
|
+
response = self._session.get(f"{self.base_url}/browser/extension")
|
|
1031
|
+
data = self._handle_response(response)
|
|
1032
|
+
return ListExtensionsResponse(**data["r"])
|
|
1033
|
+
except requests.exceptions.RequestException as e:
|
|
1034
|
+
logger.error(f"Request failed: {e}")
|
|
1035
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1036
|
+
|
|
1037
|
+
def delete_extension(self, extension_id: str):
|
|
1038
|
+
"""Delete an extension by its ID."""
|
|
1039
|
+
try:
|
|
1040
|
+
response = self._session.delete(f"{self.base_url}/browser/extension/{extension_id}")
|
|
1041
|
+
self._handle_response(response)
|
|
1042
|
+
except requests.exceptions.RequestException as e:
|
|
1043
|
+
logger.error(f"Request failed: {e}")
|
|
1044
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
885
1045
|
|
|
886
|
-
|
|
887
|
-
A list of existing browser profiles.
|
|
1046
|
+
# --- Asynchronous Client ---
|
|
888
1047
|
|
|
889
|
-
Raises:
|
|
890
|
-
ApiException: If the API request fails.
|
|
891
|
-
"""
|
|
892
|
-
try:
|
|
893
|
-
response = await self._client.get(f"{self.base_url}/browser/session")
|
|
894
|
-
data = self._handle_response(response)
|
|
895
|
-
return BrowserProfilesResponse(**data["r"])
|
|
896
|
-
except httpx.RequestError as e:
|
|
897
|
-
logger.error(f"Request failed: {e}")
|
|
898
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
899
|
-
|
|
900
|
-
@deprecated("list_sessions is deprecated, use list_profiles instead")
|
|
901
|
-
async def list_sessions(self):
|
|
902
|
-
"""Lists all browser profiles for the user."""
|
|
903
|
-
return await self.list_profiles()
|
|
904
|
-
|
|
905
|
-
async def delete_profile(self, profile_id: str):
|
|
906
|
-
"""Delete a browser profile."""
|
|
907
|
-
try:
|
|
908
|
-
response = await self._client.delete(f"{self.base_url}/browser/session/{profile_id}")
|
|
909
|
-
self._handle_response(response)
|
|
910
|
-
except httpx.RequestError as e:
|
|
911
|
-
logger.error(f"Request failed: {e}")
|
|
912
|
-
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
913
|
-
|
|
914
|
-
@deprecated("delete_session is deprecated, use delete_profile instead")
|
|
915
|
-
async def delete_session(self, session_id: str):
|
|
916
|
-
"""Delete a browser profile."""
|
|
917
|
-
await self.delete_profile(session_id)
|
|
918
|
-
|
|
919
|
-
async def upload_file(self, file: io.IOBase, name: str | None = None, purpose: str | None = None) -> UploadFileResponse:
|
|
920
|
-
"""Upload a file and return the file ID.
|
|
921
1048
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1049
|
+
class AsyncTaskHandle:
|
|
1050
|
+
"""An asynchronous handle to a running task."""
|
|
1051
|
+
|
|
1052
|
+
def __init__(self, task_id: str, client: "SmoothAsyncClient"):
|
|
1053
|
+
"""Initializes the asynchronous task handle."""
|
|
1054
|
+
self._client = client
|
|
1055
|
+
self._task_response: TaskResponse | None = None
|
|
1056
|
+
|
|
1057
|
+
self._id = task_id
|
|
1058
|
+
|
|
1059
|
+
def id(self):
|
|
1060
|
+
"""Returns the task ID."""
|
|
1061
|
+
return self._id
|
|
1062
|
+
|
|
1063
|
+
async def stop(self):
|
|
1064
|
+
"""Stops the task."""
|
|
1065
|
+
await self._client._delete_task(self._id)
|
|
1066
|
+
|
|
1067
|
+
async def result(
|
|
1068
|
+
self, timeout: int | None = None, poll_interval: float = 1
|
|
1069
|
+
) -> TaskResponse:
|
|
1070
|
+
"""Waits for the task to complete and returns the result."""
|
|
1071
|
+
if self._task_response and self._task_response.status not in [
|
|
1072
|
+
"running",
|
|
1073
|
+
"waiting",
|
|
1074
|
+
]:
|
|
1075
|
+
return self._task_response
|
|
1076
|
+
|
|
1077
|
+
if timeout is not None and timeout < 1:
|
|
1078
|
+
raise ValueError("Timeout must be at least 1 second.")
|
|
1079
|
+
if poll_interval < 0.1:
|
|
1080
|
+
raise ValueError("Poll interval must be at least 100 milliseconds.")
|
|
1081
|
+
|
|
1082
|
+
start_time = time.time()
|
|
1083
|
+
while timeout is None or (time.time() - start_time) < timeout:
|
|
1084
|
+
task_response = await self._client._get_task(self.id())
|
|
1085
|
+
self._task_response = task_response
|
|
1086
|
+
if task_response.status not in ["running", "waiting"]:
|
|
1087
|
+
return task_response
|
|
1088
|
+
await asyncio.sleep(poll_interval)
|
|
1089
|
+
raise TimeoutError(
|
|
1090
|
+
f"Task {self.id()} did not complete within {timeout} seconds."
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
async def live_url(
|
|
1094
|
+
self, interactive: bool = False, embed: bool = False, timeout: int | None = None
|
|
1095
|
+
):
|
|
1096
|
+
"""Returns the live URL for the task."""
|
|
1097
|
+
if self._task_response and self._task_response.live_url:
|
|
1098
|
+
return _encode_url(
|
|
1099
|
+
self._task_response.live_url, interactive=interactive, embed=embed
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
start_time = time.time()
|
|
1103
|
+
while timeout is None or (time.time() - start_time) < timeout:
|
|
1104
|
+
task_response = await self._client._get_task(self.id())
|
|
1105
|
+
self._task_response = task_response
|
|
1106
|
+
if self._task_response.live_url:
|
|
1107
|
+
return _encode_url(
|
|
1108
|
+
self._task_response.live_url, interactive=interactive, embed=embed
|
|
1109
|
+
)
|
|
1110
|
+
await asyncio.sleep(1)
|
|
1111
|
+
|
|
1112
|
+
raise TimeoutError(f"Live URL not available for task {self.id()}.")
|
|
1113
|
+
|
|
1114
|
+
async def recording_url(self, timeout: int | None = None) -> str:
|
|
1115
|
+
"""Returns the recording URL for the task."""
|
|
1116
|
+
if self._task_response and self._task_response.recording_url is not None:
|
|
1117
|
+
return self._task_response.recording_url
|
|
1118
|
+
|
|
1119
|
+
start_time = time.time()
|
|
1120
|
+
while timeout is None or (time.time() - start_time) < timeout:
|
|
1121
|
+
task_response = await self._client._get_task(self.id())
|
|
1122
|
+
self._task_response = task_response
|
|
1123
|
+
if task_response.recording_url is not None:
|
|
1124
|
+
if not task_response.recording_url:
|
|
1125
|
+
raise ApiError(
|
|
1126
|
+
status_code=404,
|
|
1127
|
+
detail=(
|
|
1128
|
+
f"Recording URL not available for task {self.id()}."
|
|
1129
|
+
" Set `enable_recording=True` when creating the task to enable it."
|
|
1130
|
+
),
|
|
1131
|
+
)
|
|
1132
|
+
return task_response.recording_url
|
|
1133
|
+
await asyncio.sleep(1)
|
|
1134
|
+
|
|
1135
|
+
raise TimeoutError(f"Recording URL not available for task {self.id()}.")
|
|
1136
|
+
|
|
1137
|
+
async def downloads_url(self, timeout: int | None = None) -> str:
|
|
1138
|
+
"""Returns the downloads URL for the task."""
|
|
1139
|
+
if self._task_response and self._task_response.downloads_url is not None:
|
|
1140
|
+
return self._task_response.downloads_url
|
|
1141
|
+
|
|
1142
|
+
start_time = time.time()
|
|
1143
|
+
while timeout is None or (time.time() - start_time) < timeout:
|
|
1144
|
+
task_response = await self._client._get_task(
|
|
1145
|
+
self.id(), query_params={"downloads": "true"}
|
|
1146
|
+
)
|
|
1147
|
+
self._task_response = task_response
|
|
1148
|
+
if task_response.downloads_url is not None:
|
|
1149
|
+
if not task_response.downloads_url:
|
|
1150
|
+
raise ApiError(
|
|
1151
|
+
status_code=404,
|
|
1152
|
+
detail=(
|
|
1153
|
+
f"Downloads URL not available for task {self.id()}."
|
|
1154
|
+
" Make sure the task downloaded files during its execution."
|
|
1155
|
+
),
|
|
1156
|
+
)
|
|
1157
|
+
return task_response.downloads_url
|
|
1158
|
+
await asyncio.sleep(1)
|
|
1159
|
+
|
|
1160
|
+
raise TimeoutError(f"Downloads URL not available for task {self.id()}.")
|
|
926
1161
|
|
|
927
|
-
Returns:
|
|
928
|
-
The file ID assigned to the uploaded file.
|
|
929
1162
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1163
|
+
class SmoothAsyncClient(BaseClient):
|
|
1164
|
+
"""An asynchronous client for the API."""
|
|
1165
|
+
|
|
1166
|
+
def __init__(
|
|
1167
|
+
self,
|
|
1168
|
+
api_key: str | None = None,
|
|
1169
|
+
base_url: str = BASE_URL,
|
|
1170
|
+
api_version: str = "v1",
|
|
1171
|
+
timeout: int = 30,
|
|
1172
|
+
):
|
|
1173
|
+
"""Initializes the asynchronous client."""
|
|
1174
|
+
super().__init__(api_key, base_url, api_version)
|
|
1175
|
+
self._client = httpx.AsyncClient(headers=self.headers, timeout=timeout)
|
|
1176
|
+
|
|
1177
|
+
async def __aenter__(self):
|
|
1178
|
+
"""Enters the asynchronous context manager."""
|
|
1179
|
+
return self
|
|
1180
|
+
|
|
1181
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any):
|
|
1182
|
+
"""Exits the asynchronous context manager."""
|
|
1183
|
+
await self.close()
|
|
1184
|
+
|
|
1185
|
+
async def _submit_task(self, payload: TaskRequest) -> TaskResponse:
|
|
1186
|
+
"""Submits a task to be run asynchronously."""
|
|
1187
|
+
try:
|
|
1188
|
+
response = await self._client.post(
|
|
1189
|
+
f"{self.base_url}/task", json=payload.model_dump()
|
|
1190
|
+
)
|
|
1191
|
+
data = self._handle_response(response)
|
|
1192
|
+
return TaskResponse(**data["r"])
|
|
1193
|
+
except httpx.RequestError as e:
|
|
1194
|
+
logger.error(f"Request failed: {e}")
|
|
1195
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1196
|
+
|
|
1197
|
+
async def _get_task(
|
|
1198
|
+
self, task_id: str, query_params: dict[str, Any] | None = None
|
|
1199
|
+
) -> TaskResponse:
|
|
1200
|
+
"""Retrieves the status and result of a task asynchronously."""
|
|
1201
|
+
if not task_id:
|
|
1202
|
+
raise ValueError("Task ID cannot be empty.")
|
|
1203
|
+
|
|
1204
|
+
try:
|
|
1205
|
+
url = f"{self.base_url}/task/{task_id}"
|
|
1206
|
+
response = await self._client.get(url, params=query_params)
|
|
1207
|
+
data = self._handle_response(response)
|
|
1208
|
+
return TaskResponse(**data["r"])
|
|
1209
|
+
except httpx.RequestError as e:
|
|
1210
|
+
logger.error(f"Request failed: {e}")
|
|
1211
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1212
|
+
|
|
1213
|
+
async def _delete_task(self, task_id: str):
|
|
1214
|
+
"""Deletes a task asynchronously."""
|
|
1215
|
+
if not task_id:
|
|
1216
|
+
raise ValueError("Task ID cannot be empty.")
|
|
1217
|
+
|
|
1218
|
+
try:
|
|
1219
|
+
response = await self._client.delete(f"{self.base_url}/task/{task_id}")
|
|
1220
|
+
self._handle_response(response)
|
|
1221
|
+
except httpx.RequestError as e:
|
|
1222
|
+
logger.error(f"Request failed: {e}")
|
|
1223
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1224
|
+
|
|
1225
|
+
async def run(
|
|
1226
|
+
self,
|
|
1227
|
+
task: str,
|
|
1228
|
+
response_model: dict[str, Any] | Type[BaseModel] | None = None,
|
|
1229
|
+
url: str | None = None,
|
|
1230
|
+
metadata: dict[str, str | int | float | bool] | None = None,
|
|
1231
|
+
files: list[str] | None = None,
|
|
1232
|
+
agent: Literal["smooth"] = "smooth",
|
|
1233
|
+
max_steps: int = 32,
|
|
1234
|
+
device: Literal["desktop", "mobile"] = "mobile",
|
|
1235
|
+
allowed_urls: list[str] | None = None,
|
|
1236
|
+
enable_recording: bool = True,
|
|
1237
|
+
session_id: str | None = None,
|
|
1238
|
+
profile_id: str | None = None,
|
|
1239
|
+
profile_read_only: bool = False,
|
|
1240
|
+
stealth_mode: bool = False,
|
|
1241
|
+
proxy_server: str | None = None,
|
|
1242
|
+
proxy_username: str | None = None,
|
|
1243
|
+
proxy_password: str | None = None,
|
|
1244
|
+
certificates: list[Certificate] | None = None,
|
|
1245
|
+
use_adblock: bool | None = True,
|
|
1246
|
+
additional_tools: dict[str, dict[str, Any] | None] | None = None,
|
|
1247
|
+
experimental_features: dict[str, Any] | None = None,
|
|
1248
|
+
) -> AsyncTaskHandle:
|
|
1249
|
+
"""Runs a task and returns a handle to the task asynchronously.
|
|
1250
|
+
|
|
1251
|
+
This method submits a task and returns an `AsyncTaskHandle` object
|
|
1252
|
+
that can be used to get the result of the task.
|
|
1253
|
+
|
|
1254
|
+
Args:
|
|
1255
|
+
task: The task to run.
|
|
1256
|
+
response_model: If provided, the schema describing the desired output structure.
|
|
1257
|
+
url: The starting URL for the task. If not provided, the agent will infer it from the task.
|
|
1258
|
+
metadata: A dictionary containing variables or parameters that will be passed to the agent.
|
|
1259
|
+
files: A list of file ids to pass to the agent.
|
|
1260
|
+
agent: The agent to use for the task.
|
|
1261
|
+
max_steps: Maximum number of steps the agent can take (max 64).
|
|
1262
|
+
device: Device type for the task. Default is mobile.
|
|
1263
|
+
allowed_urls: List of allowed URL patterns using wildcard syntax (e.g., https://*example.com/*).
|
|
1264
|
+
If None, all URLs are allowed.
|
|
1265
|
+
enable_recording: Enable video recording of the task execution.
|
|
1266
|
+
session_id: (Deprecated, now `profile_id`) Browser session ID to use.
|
|
1267
|
+
profile_id: Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials.
|
|
1268
|
+
profile_read_only: If true, the profile specified by `profile_id` will be loaded in read-only mode.
|
|
1269
|
+
stealth_mode: Run the browser in stealth mode.
|
|
1270
|
+
proxy_server: Proxy server address to route browser traffic through.
|
|
1271
|
+
proxy_username: Proxy server username.
|
|
1272
|
+
proxy_password: Proxy server password.
|
|
1273
|
+
certificates: List of client certificates to use when accessing secure websites.
|
|
1274
|
+
Each certificate is a dictionary with the following fields:
|
|
1275
|
+
- `file` (required): p12 file object to be uploaded (e.g., open("cert.p12", "rb")).
|
|
1276
|
+
- `password` (optional): Password to decrypt the certificate file.
|
|
1277
|
+
use_adblock: Enable adblock for the browser session. Default is True.
|
|
1278
|
+
additional_tools: Additional tools to enable for the task.
|
|
1279
|
+
experimental_features: Experimental features to enable for the task.
|
|
1280
|
+
|
|
1281
|
+
Returns:
|
|
1282
|
+
A handle to the running task.
|
|
1283
|
+
|
|
1284
|
+
Raises:
|
|
1285
|
+
ApiException: If the API request fails.
|
|
1286
|
+
"""
|
|
1287
|
+
payload = TaskRequest(
|
|
1288
|
+
task=task,
|
|
1289
|
+
response_model=response_model
|
|
1290
|
+
if isinstance(response_model, dict | None)
|
|
1291
|
+
else response_model.model_json_schema(),
|
|
1292
|
+
url=url,
|
|
1293
|
+
metadata=metadata,
|
|
1294
|
+
files=files,
|
|
1295
|
+
agent=agent,
|
|
1296
|
+
max_steps=max_steps,
|
|
1297
|
+
device=device,
|
|
1298
|
+
allowed_urls=allowed_urls,
|
|
1299
|
+
enable_recording=enable_recording,
|
|
1300
|
+
profile_id=profile_id or session_id,
|
|
1301
|
+
profile_read_only=profile_read_only,
|
|
1302
|
+
stealth_mode=stealth_mode,
|
|
1303
|
+
proxy_server=proxy_server,
|
|
1304
|
+
proxy_username=proxy_username,
|
|
1305
|
+
proxy_password=proxy_password,
|
|
1306
|
+
certificates=_process_certificates(certificates),
|
|
1307
|
+
use_adblock=use_adblock,
|
|
1308
|
+
additional_tools=additional_tools,
|
|
1309
|
+
experimental_features=experimental_features,
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
initial_response = await self._submit_task(payload)
|
|
1313
|
+
return AsyncTaskHandle(initial_response.id, self)
|
|
1314
|
+
|
|
1315
|
+
async def open_session(
|
|
1316
|
+
self,
|
|
1317
|
+
profile_id: str | None = None,
|
|
1318
|
+
session_id: str | None = None,
|
|
1319
|
+
live_view: bool = True,
|
|
1320
|
+
device: Literal["desktop", "mobile"] = "desktop",
|
|
1321
|
+
url: str | None = None,
|
|
1322
|
+
proxy_server: str | None = None,
|
|
1323
|
+
proxy_username: str | None = None,
|
|
1324
|
+
proxy_password: str | None = None,
|
|
1325
|
+
) -> BrowserSessionHandle:
|
|
1326
|
+
"""Opens an interactive browser instance asynchronously.
|
|
1327
|
+
|
|
1328
|
+
Args:
|
|
1329
|
+
profile_id: The profile ID to use for the session. If None, a new profile will be created.
|
|
1330
|
+
session_id: (Deprecated, now `profile_id`) The session ID to associate with the browser.
|
|
1331
|
+
live_view: Whether to enable live view for the session.
|
|
1332
|
+
device: The device type to use for the session. Defaults to "desktop".
|
|
1333
|
+
url: The URL to open in the browser session.
|
|
1334
|
+
proxy_server: Proxy server address to route browser traffic through.
|
|
1335
|
+
proxy_username: Proxy server username.
|
|
1336
|
+
proxy_password: Proxy server password.
|
|
1337
|
+
|
|
1338
|
+
Returns:
|
|
1339
|
+
The browser session details, including the live URL.
|
|
1340
|
+
|
|
1341
|
+
Raises:
|
|
1342
|
+
ApiException: If the API request fails.
|
|
1343
|
+
"""
|
|
1344
|
+
try:
|
|
1345
|
+
response = await self._client.post(
|
|
1346
|
+
f"{self.base_url}/browser/session",
|
|
1347
|
+
json=BrowserSessionRequest(
|
|
1348
|
+
profile_id=profile_id or session_id,
|
|
1349
|
+
live_view=live_view,
|
|
1350
|
+
device=device,
|
|
1351
|
+
url=url,
|
|
1352
|
+
proxy_server=proxy_server,
|
|
1353
|
+
proxy_username=proxy_username,
|
|
1354
|
+
proxy_password=proxy_password,
|
|
1355
|
+
).model_dump(),
|
|
1356
|
+
)
|
|
1357
|
+
data = self._handle_response(response)
|
|
1358
|
+
return BrowserSessionHandle(
|
|
1359
|
+
browser_session=BrowserSessionResponse(**data["r"])
|
|
1360
|
+
)
|
|
1361
|
+
except httpx.RequestError as e:
|
|
1362
|
+
logger.error(f"Request failed: {e}")
|
|
1363
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1364
|
+
|
|
1365
|
+
async def close_session(self, live_id: str):
|
|
1366
|
+
"""Closes a browser session."""
|
|
1367
|
+
try:
|
|
1368
|
+
response = await self._client.delete(
|
|
1369
|
+
f"{self.base_url}/browser/session/{live_id}"
|
|
1370
|
+
)
|
|
1371
|
+
self._handle_response(response)
|
|
1372
|
+
except httpx.RequestError as e:
|
|
1373
|
+
logger.error(f"Request failed: {e}")
|
|
1374
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1375
|
+
|
|
1376
|
+
async def list_profiles(self):
|
|
1377
|
+
"""Lists all browser profiles for the user.
|
|
1378
|
+
|
|
1379
|
+
Returns:
|
|
1380
|
+
A list of existing browser profiles.
|
|
1381
|
+
|
|
1382
|
+
Raises:
|
|
1383
|
+
ApiException: If the API request fails.
|
|
1384
|
+
"""
|
|
1385
|
+
try:
|
|
1386
|
+
response = await self._client.get(f"{self.base_url}/browser/profile")
|
|
1387
|
+
data = self._handle_response(response)
|
|
1388
|
+
return BrowserProfilesResponse(**data["r"])
|
|
1389
|
+
except httpx.RequestError as e:
|
|
1390
|
+
logger.error(f"Request failed: {e}")
|
|
1391
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1392
|
+
|
|
1393
|
+
@deprecated("list_sessions is deprecated, use list_profiles instead")
|
|
1394
|
+
async def list_sessions(self):
|
|
1395
|
+
"""Lists all browser profiles for the user."""
|
|
1396
|
+
return await self.list_profiles()
|
|
1397
|
+
|
|
1398
|
+
async def delete_profile(self, profile_id: str):
|
|
1399
|
+
"""Delete a browser profile."""
|
|
1400
|
+
try:
|
|
1401
|
+
response = await self._client.delete(
|
|
1402
|
+
f"{self.base_url}/browser/profile/{profile_id}"
|
|
1403
|
+
)
|
|
1404
|
+
self._handle_response(response)
|
|
1405
|
+
except httpx.RequestError as e:
|
|
1406
|
+
logger.error(f"Request failed: {e}")
|
|
1407
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1408
|
+
|
|
1409
|
+
@deprecated("delete_session is deprecated, use delete_profile instead")
|
|
1410
|
+
async def delete_session(self, session_id: str):
|
|
1411
|
+
"""Delete a browser profile."""
|
|
1412
|
+
await self.delete_profile(session_id)
|
|
1413
|
+
|
|
1414
|
+
async def upload_file(
|
|
1415
|
+
self, file: io.IOBase, name: str | None = None, purpose: str | None = None
|
|
1416
|
+
) -> UploadFileResponse:
|
|
1417
|
+
"""Upload a file and return the file ID.
|
|
1418
|
+
|
|
1419
|
+
Args:
|
|
1420
|
+
file: File object to be uploaded.
|
|
1421
|
+
name: Optional custom name for the file. If not provided, the original file name will be used.
|
|
1422
|
+
purpose: Optional short description of the file to describe its purpose (i.e., 'the bank statement pdf').
|
|
1423
|
+
|
|
1424
|
+
Returns:
|
|
1425
|
+
The file ID assigned to the uploaded file.
|
|
1426
|
+
|
|
1427
|
+
Raises:
|
|
1428
|
+
ValueError: If the file doesn't exist or can't be read.
|
|
1429
|
+
ApiError: If the API request fails.
|
|
1430
|
+
"""
|
|
1431
|
+
try:
|
|
1432
|
+
name = name or getattr(file, "name", None)
|
|
1433
|
+
if name is None:
|
|
1434
|
+
raise ValueError(
|
|
1435
|
+
"File name must be provided or the file object must have a 'name' attribute."
|
|
1436
|
+
)
|
|
1437
|
+
|
|
1438
|
+
if purpose:
|
|
1439
|
+
data = {"file_purpose": purpose}
|
|
1440
|
+
else:
|
|
1441
|
+
data = None
|
|
1442
|
+
|
|
1443
|
+
files = {"file": (Path(name).name, file)}
|
|
1444
|
+
response = await self._client.post(
|
|
1445
|
+
f"{self.base_url}/file", files=files, data=data
|
|
1446
|
+
)
|
|
1447
|
+
data = self._handle_response(response)
|
|
1448
|
+
return UploadFileResponse(**data["r"])
|
|
1449
|
+
except httpx.RequestError as e:
|
|
1450
|
+
logger.error(f"Request failed: {e}")
|
|
1451
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1452
|
+
|
|
1453
|
+
async def delete_file(self, file_id: str):
|
|
1454
|
+
"""Delete a file by its ID."""
|
|
1455
|
+
try:
|
|
1456
|
+
response = await self._client.delete(f"{self.base_url}/file/{file_id}")
|
|
1457
|
+
self._handle_response(response)
|
|
1458
|
+
except httpx.RequestError as e:
|
|
1459
|
+
logger.error(f"Request failed: {e}")
|
|
1460
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1461
|
+
|
|
1462
|
+
async def upload_extension(self, file: io.IOBase, name: str | None = None) -> UploadExtensionResponse:
|
|
1463
|
+
"""Upload an extension and return the extension ID."""
|
|
1464
|
+
try:
|
|
1465
|
+
name = name or getattr(file, "name", None)
|
|
1466
|
+
if name is None:
|
|
1467
|
+
raise ValueError(
|
|
1468
|
+
"File name must be provided or the file object must have a 'name' attribute."
|
|
1469
|
+
)
|
|
1470
|
+
files = {"file": (Path(name).name, file)}
|
|
1471
|
+
response = await self._client.post(f"{self.base_url}/browser/extension", files=files)
|
|
1472
|
+
data = self._handle_response(response)
|
|
1473
|
+
return UploadExtensionResponse(**data["r"])
|
|
1474
|
+
except httpx.RequestError as e:
|
|
1475
|
+
logger.error(f"Request failed: {e}")
|
|
1476
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1477
|
+
|
|
1478
|
+
async def list_extensions(self) -> ListExtensionsResponse:
|
|
1479
|
+
"""List all extensions."""
|
|
1480
|
+
try:
|
|
1481
|
+
response = await self._client.get(f"{self.base_url}/browser/extension")
|
|
1482
|
+
data = self._handle_response(response)
|
|
1483
|
+
return ListExtensionsResponse(**data["r"])
|
|
1484
|
+
except httpx.RequestError as e:
|
|
1485
|
+
logger.error(f"Request failed: {e}")
|
|
1486
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1487
|
+
|
|
1488
|
+
async def delete_extension(self, extension_id: str):
|
|
1489
|
+
"""Delete an extension by its ID."""
|
|
1490
|
+
try:
|
|
1491
|
+
response = await self._client.delete(f"{self.base_url}/browser/extension/{extension_id}")
|
|
1492
|
+
self._handle_response(response)
|
|
1493
|
+
except httpx.RequestError as e:
|
|
1494
|
+
logger.error(f"Request failed: {e}")
|
|
1495
|
+
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
1496
|
+
|
|
1497
|
+
async def close(self):
|
|
1498
|
+
"""Closes the async client session."""
|
|
1499
|
+
await self._client.aclose()
|
|
964
1500
|
|
|
965
1501
|
|
|
966
1502
|
# Export public API
|
|
967
1503
|
__all__ = [
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1504
|
+
"SmoothClient",
|
|
1505
|
+
"SmoothAsyncClient",
|
|
1506
|
+
"TaskHandle",
|
|
1507
|
+
"AsyncTaskHandle",
|
|
1508
|
+
"BrowserSessionHandle",
|
|
1509
|
+
"TaskRequest",
|
|
1510
|
+
"TaskResponse",
|
|
1511
|
+
"BrowserSessionRequest",
|
|
1512
|
+
"BrowserSessionResponse",
|
|
1513
|
+
"BrowserSessionsResponse",
|
|
1514
|
+
"UploadFileResponse",
|
|
1515
|
+
"UploadExtensionResponse",
|
|
1516
|
+
"ListExtensionsResponse",
|
|
1517
|
+
"Extension",
|
|
1518
|
+
"Certificate",
|
|
1519
|
+
"ApiError",
|
|
1520
|
+
"TimeoutError",
|
|
981
1521
|
]
|