smooth-py 0.3.0.post2__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of smooth-py might be problematic. Click here for more details.
- {smooth_py-0.3.0.post2 → smooth_py-0.3.1}/PKG-INFO +1 -1
- {smooth_py-0.3.0.post2 → smooth_py-0.3.1}/pyproject.toml +1 -1
- {smooth_py-0.3.0.post2 → smooth_py-0.3.1}/src/smooth/__init__.py +130 -11
- {smooth_py-0.3.0.post2 → smooth_py-0.3.1}/README.md +0 -0
- {smooth_py-0.3.0.post2 → smooth_py-0.3.1}/src/smooth/mcp/__init__.py +0 -0
- {smooth_py-0.3.0.post2 → smooth_py-0.3.1}/src/smooth/mcp/server.py +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Smooth python SDK."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import base64
|
|
4
5
|
import io
|
|
5
6
|
import logging
|
|
6
7
|
import os
|
|
@@ -8,7 +9,7 @@ import time
|
|
|
8
9
|
import urllib.parse
|
|
9
10
|
import warnings
|
|
10
11
|
from pathlib import Path
|
|
11
|
-
from typing import Any, Literal, Type
|
|
12
|
+
from typing import Any, Literal, NotRequired, Type, TypedDict
|
|
12
13
|
|
|
13
14
|
import httpx
|
|
14
15
|
import requests
|
|
@@ -33,7 +34,47 @@ def _encode_url(url: str, interactive: bool = True, embed: bool = False) -> str:
|
|
|
33
34
|
|
|
34
35
|
|
|
35
36
|
# --- Models ---
|
|
36
|
-
|
|
37
|
+
|
|
38
|
+
class Certificate(TypedDict):
|
|
39
|
+
"""Client certificate for accessing secure websites.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
file: p12 file object to be uploaded (e.g., open("cert.p12", "rb")).
|
|
43
|
+
password: Password to decrypt the certificate file. Optional.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
file: str | io.IOBase # Required - base64 string or binary IO
|
|
47
|
+
password: NotRequired[str] # Optional
|
|
48
|
+
filters: NotRequired[list[str]] # Optional - TODO: Reserved for future use to specify URL patterns where the certificate should be applied.
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _process_certificates(certificates: list[Certificate] | None) -> list[dict[str, Any]] | None:
|
|
52
|
+
"""Process certificates, converting binary IO to base64-encoded strings.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
certificates: List of certificates with file field as string or binary IO.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
List of certificates with file field as base64-encoded string, or None if input is None.
|
|
59
|
+
"""
|
|
60
|
+
if certificates is None:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
processed_certs = []
|
|
64
|
+
for cert in certificates:
|
|
65
|
+
processed_cert = dict(cert) # Create a copy
|
|
66
|
+
|
|
67
|
+
file_content = processed_cert["file"]
|
|
68
|
+
if isinstance(file_content, io.IOBase):
|
|
69
|
+
# Read the binary content and encode to base64
|
|
70
|
+
binary_data = file_content.read()
|
|
71
|
+
processed_cert["file"] = base64.b64encode(binary_data).decode("utf-8")
|
|
72
|
+
elif not isinstance(file_content, str):
|
|
73
|
+
raise TypeError(f"Certificate file must be a string or binary IO, got {type(file_content)}")
|
|
74
|
+
|
|
75
|
+
processed_certs.append(processed_cert)
|
|
76
|
+
|
|
77
|
+
return processed_certs
|
|
37
78
|
|
|
38
79
|
|
|
39
80
|
class TaskResponse(BaseModel):
|
|
@@ -48,6 +89,8 @@ class TaskResponse(BaseModel):
|
|
|
48
89
|
device: Literal["desktop", "mobile"] | None = Field(default=None, description="The device type used for the task.")
|
|
49
90
|
live_url: str | None = Field(default=None, description="The URL to view and interact with the task execution.")
|
|
50
91
|
recording_url: str | None = Field(default=None, description="The URL to view the task recording.")
|
|
92
|
+
downloads_url: str | None = Field(default=None, description="The URL of the archive containing the downloaded files.")
|
|
93
|
+
created_at: int | None = Field(default=None, description="The timestamp when the task was created.")
|
|
51
94
|
|
|
52
95
|
|
|
53
96
|
class TaskRequest(BaseModel):
|
|
@@ -69,7 +112,7 @@ class TaskRequest(BaseModel):
|
|
|
69
112
|
files: list[str] | None = Field(default=None, description="A list of file ids to pass to the agent.")
|
|
70
113
|
agent: Literal["smooth", "smooth-lite"] = Field(default="smooth", description="The agent to use for the task.")
|
|
71
114
|
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="
|
|
115
|
+
device: Literal["desktop", "mobile"] = Field(default="desktop", description="Device type for the task. Default is desktop.")
|
|
73
116
|
allowed_urls: list[str] | None = Field(
|
|
74
117
|
default=None,
|
|
75
118
|
description=(
|
|
@@ -97,6 +140,16 @@ class TaskRequest(BaseModel):
|
|
|
97
140
|
)
|
|
98
141
|
proxy_username: str | None = Field(default=None, description="Proxy server username.")
|
|
99
142
|
proxy_password: str | None = Field(default=None, description="Proxy server password.")
|
|
143
|
+
certificates: list[dict[str, Any]] | None = Field(
|
|
144
|
+
default=None,
|
|
145
|
+
description=(
|
|
146
|
+
"List of client certificates to use when accessing secure websites. "
|
|
147
|
+
"Each certificate is a dictionary with the following fields:\n"
|
|
148
|
+
" - `file`: p12 file object to be uploaded (e.g., open('cert.p12', 'rb')).\n"
|
|
149
|
+
" - `password` (optional): Password to decrypt the certificate file."
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
use_adblock: bool | None = Field(default=True, description="Enable adblock for the browser session. Default is True.")
|
|
100
153
|
experimental_features: dict[str, Any] | None = Field(
|
|
101
154
|
default=None, description="Experimental features to enable for the task."
|
|
102
155
|
)
|
|
@@ -413,6 +466,28 @@ class TaskHandle:
|
|
|
413
466
|
time.sleep(1)
|
|
414
467
|
raise TimeoutError(f"Recording URL not available for task {self.id()}.")
|
|
415
468
|
|
|
469
|
+
def downloads_url(self, timeout: int | None = None) -> str:
|
|
470
|
+
"""Returns the downloads URL for the task."""
|
|
471
|
+
if self._task_response and self._task_response.downloads_url is not None:
|
|
472
|
+
return self._task_response.downloads_url
|
|
473
|
+
|
|
474
|
+
start_time = time.time()
|
|
475
|
+
while timeout is None or (time.time() - start_time) < timeout:
|
|
476
|
+
task_response = self._client._get_task(self.id(), query_params={"downloads": "true"})
|
|
477
|
+
self._task_response = task_response
|
|
478
|
+
if task_response.downloads_url is not None:
|
|
479
|
+
if not task_response.downloads_url:
|
|
480
|
+
raise ApiError(
|
|
481
|
+
status_code=404,
|
|
482
|
+
detail=(
|
|
483
|
+
f"Downloads URL not available for task {self.id()}."
|
|
484
|
+
" Make sure the task downloaded files during its execution."
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
return task_response.downloads_url
|
|
488
|
+
time.sleep(1)
|
|
489
|
+
raise TimeoutError(f"Downloads URL not available for task {self.id()}.")
|
|
490
|
+
|
|
416
491
|
|
|
417
492
|
class SmoothClient(BaseClient):
|
|
418
493
|
"""A synchronous client for the API."""
|
|
@@ -439,20 +514,21 @@ class SmoothClient(BaseClient):
|
|
|
439
514
|
def _submit_task(self, payload: TaskRequest) -> TaskResponse:
|
|
440
515
|
"""Submits a task to be run."""
|
|
441
516
|
try:
|
|
442
|
-
response = self._session.post(f"{self.base_url}/task", json=payload.model_dump(
|
|
517
|
+
response = self._session.post(f"{self.base_url}/task", json=payload.model_dump())
|
|
443
518
|
data = self._handle_response(response)
|
|
444
519
|
return TaskResponse(**data["r"])
|
|
445
520
|
except requests.exceptions.RequestException as e:
|
|
446
521
|
logger.error(f"Request failed: {e}")
|
|
447
522
|
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
448
523
|
|
|
449
|
-
def _get_task(self, task_id: str) -> TaskResponse:
|
|
524
|
+
def _get_task(self, task_id: str, query_params: dict[str, Any] | None = None) -> TaskResponse:
|
|
450
525
|
"""Retrieves the status and result of a task."""
|
|
451
526
|
if not task_id:
|
|
452
527
|
raise ValueError("Task ID cannot be empty.")
|
|
453
528
|
|
|
454
529
|
try:
|
|
455
|
-
|
|
530
|
+
url = f"{self.base_url}/task/{task_id}"
|
|
531
|
+
response = self._session.get(url, params=query_params)
|
|
456
532
|
data = self._handle_response(response)
|
|
457
533
|
return TaskResponse(**data["r"])
|
|
458
534
|
except requests.exceptions.RequestException as e:
|
|
@@ -490,6 +566,8 @@ class SmoothClient(BaseClient):
|
|
|
490
566
|
proxy_server: str | None = None,
|
|
491
567
|
proxy_username: str | None = None,
|
|
492
568
|
proxy_password: str | None = None,
|
|
569
|
+
certificates: list[Certificate] | None = None,
|
|
570
|
+
use_adblock: bool | None = True,
|
|
493
571
|
experimental_features: dict[str, Any] | None = None,
|
|
494
572
|
) -> TaskHandle:
|
|
495
573
|
"""Runs a task and returns a handle to the task.
|
|
@@ -516,6 +594,11 @@ class SmoothClient(BaseClient):
|
|
|
516
594
|
proxy_server: Proxy server url to route browser traffic through.
|
|
517
595
|
proxy_username: Proxy server username.
|
|
518
596
|
proxy_password: Proxy server password.
|
|
597
|
+
certificates: List of client certificates to use when accessing secure websites.
|
|
598
|
+
Each certificate is a dictionary with the following fields:
|
|
599
|
+
- `file` (required): p12 file object to be uploaded (e.g., open("cert.p12", "rb")).
|
|
600
|
+
- `password` (optional): Password to decrypt the certificate file, if password-protected.
|
|
601
|
+
use_adblock: Enable adblock for the browser session. Default is True.
|
|
519
602
|
experimental_features: Experimental features to enable for the task.
|
|
520
603
|
|
|
521
604
|
Returns:
|
|
@@ -541,6 +624,8 @@ class SmoothClient(BaseClient):
|
|
|
541
624
|
proxy_server=proxy_server,
|
|
542
625
|
proxy_username=proxy_username,
|
|
543
626
|
proxy_password=proxy_password,
|
|
627
|
+
certificates=_process_certificates(certificates),
|
|
628
|
+
use_adblock=use_adblock,
|
|
544
629
|
experimental_features=experimental_features,
|
|
545
630
|
)
|
|
546
631
|
initial_response = self._submit_task(payload)
|
|
@@ -566,7 +651,7 @@ class SmoothClient(BaseClient):
|
|
|
566
651
|
try:
|
|
567
652
|
response = self._session.post(
|
|
568
653
|
f"{self.base_url}/browser/session",
|
|
569
|
-
json=BrowserSessionRequest(profile_id=profile_id or session_id, live_view=live_view).model_dump(
|
|
654
|
+
json=BrowserSessionRequest(profile_id=profile_id or session_id, live_view=live_view).model_dump(),
|
|
570
655
|
)
|
|
571
656
|
data = self._handle_response(response)
|
|
572
657
|
return BrowserSessionHandle(browser_session=BrowserSessionResponse(**data["r"]))
|
|
@@ -740,6 +825,29 @@ class AsyncTaskHandle:
|
|
|
740
825
|
|
|
741
826
|
raise TimeoutError(f"Recording URL not available for task {self.id()}.")
|
|
742
827
|
|
|
828
|
+
async def downloads_url(self, timeout: int | None = None) -> str:
|
|
829
|
+
"""Returns the downloads URL for the task."""
|
|
830
|
+
if self._task_response and self._task_response.downloads_url is not None:
|
|
831
|
+
return self._task_response.downloads_url
|
|
832
|
+
|
|
833
|
+
start_time = time.time()
|
|
834
|
+
while timeout is None or (time.time() - start_time) < timeout:
|
|
835
|
+
task_response = await self._client._get_task(self.id(), query_params={"downloads": "true"})
|
|
836
|
+
self._task_response = task_response
|
|
837
|
+
if task_response.downloads_url is not None:
|
|
838
|
+
if not task_response.downloads_url:
|
|
839
|
+
raise ApiError(
|
|
840
|
+
status_code=404,
|
|
841
|
+
detail=(
|
|
842
|
+
f"Downloads URL not available for task {self.id()}."
|
|
843
|
+
" Make sure the task downloaded files during its execution."
|
|
844
|
+
)
|
|
845
|
+
)
|
|
846
|
+
return task_response.downloads_url
|
|
847
|
+
await asyncio.sleep(1)
|
|
848
|
+
|
|
849
|
+
raise TimeoutError(f"Downloads URL not available for task {self.id()}.")
|
|
850
|
+
|
|
743
851
|
|
|
744
852
|
class SmoothAsyncClient(BaseClient):
|
|
745
853
|
"""An asynchronous client for the API."""
|
|
@@ -760,20 +868,21 @@ class SmoothAsyncClient(BaseClient):
|
|
|
760
868
|
async def _submit_task(self, payload: TaskRequest) -> TaskResponse:
|
|
761
869
|
"""Submits a task to be run asynchronously."""
|
|
762
870
|
try:
|
|
763
|
-
response = await self._client.post(f"{self.base_url}/task", json=payload.model_dump(
|
|
871
|
+
response = await self._client.post(f"{self.base_url}/task", json=payload.model_dump())
|
|
764
872
|
data = self._handle_response(response)
|
|
765
873
|
return TaskResponse(**data["r"])
|
|
766
874
|
except httpx.RequestError as e:
|
|
767
875
|
logger.error(f"Request failed: {e}")
|
|
768
876
|
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
769
877
|
|
|
770
|
-
async def _get_task(self, task_id: str) -> TaskResponse:
|
|
878
|
+
async def _get_task(self, task_id: str, query_params: dict[str, Any] | None = None) -> TaskResponse:
|
|
771
879
|
"""Retrieves the status and result of a task asynchronously."""
|
|
772
880
|
if not task_id:
|
|
773
881
|
raise ValueError("Task ID cannot be empty.")
|
|
774
882
|
|
|
775
883
|
try:
|
|
776
|
-
|
|
884
|
+
url = f"{self.base_url}/task/{task_id}"
|
|
885
|
+
response = await self._client.get(url, params=query_params)
|
|
777
886
|
data = self._handle_response(response)
|
|
778
887
|
return TaskResponse(**data["r"])
|
|
779
888
|
except httpx.RequestError as e:
|
|
@@ -811,6 +920,8 @@ class SmoothAsyncClient(BaseClient):
|
|
|
811
920
|
proxy_server: str | None = None,
|
|
812
921
|
proxy_username: str | None = None,
|
|
813
922
|
proxy_password: str | None = None,
|
|
923
|
+
certificates: list[Certificate] | None = None,
|
|
924
|
+
use_adblock: bool | None = True,
|
|
814
925
|
experimental_features: dict[str, Any] | None = None,
|
|
815
926
|
) -> AsyncTaskHandle:
|
|
816
927
|
"""Runs a task and returns a handle to the task asynchronously.
|
|
@@ -837,6 +948,11 @@ class SmoothAsyncClient(BaseClient):
|
|
|
837
948
|
proxy_server: Proxy server url to route browser traffic through.
|
|
838
949
|
proxy_username: Proxy server username.
|
|
839
950
|
proxy_password: Proxy server password.
|
|
951
|
+
certificates: List of client certificates to use when accessing secure websites.
|
|
952
|
+
Each certificate is a dictionary with the following fields:
|
|
953
|
+
- `file` (required): p12 file object to be uploaded (e.g., open("cert.p12", "rb")).
|
|
954
|
+
- `password` (optional): Password to decrypt the certificate file.
|
|
955
|
+
use_adblock: Enable adblock for the browser session. Default is True.
|
|
840
956
|
experimental_features: Experimental features to enable for the task.
|
|
841
957
|
|
|
842
958
|
Returns:
|
|
@@ -862,6 +978,8 @@ class SmoothAsyncClient(BaseClient):
|
|
|
862
978
|
proxy_server=proxy_server,
|
|
863
979
|
proxy_username=proxy_username,
|
|
864
980
|
proxy_password=proxy_password,
|
|
981
|
+
certificates=_process_certificates(certificates),
|
|
982
|
+
use_adblock=use_adblock,
|
|
865
983
|
experimental_features=experimental_features,
|
|
866
984
|
)
|
|
867
985
|
|
|
@@ -887,7 +1005,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
887
1005
|
try:
|
|
888
1006
|
response = await self._client.post(
|
|
889
1007
|
f"{self.base_url}/browser/session",
|
|
890
|
-
json=BrowserSessionRequest(profile_id=profile_id or session_id, live_view=live_view).model_dump(
|
|
1008
|
+
json=BrowserSessionRequest(profile_id=profile_id or session_id, live_view=live_view).model_dump(),
|
|
891
1009
|
)
|
|
892
1010
|
data = self._handle_response(response)
|
|
893
1011
|
return BrowserSessionHandle(browser_session=BrowserSessionResponse(**data["r"]))
|
|
@@ -1000,6 +1118,7 @@ __all__ = [
|
|
|
1000
1118
|
"BrowserSessionResponse",
|
|
1001
1119
|
"BrowserSessionsResponse",
|
|
1002
1120
|
"UploadFileResponse",
|
|
1121
|
+
"Certificate",
|
|
1003
1122
|
"ApiError",
|
|
1004
1123
|
"TimeoutError",
|
|
1005
1124
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|