smooth-py 0.3.0.dev20251014__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.dev20251014 → smooth_py-0.3.1}/PKG-INFO +1 -1
- {smooth_py-0.3.0.dev20251014 → smooth_py-0.3.1}/pyproject.toml +1 -1
- {smooth_py-0.3.0.dev20251014 → smooth_py-0.3.1}/src/smooth/__init__.py +161 -18
- {smooth_py-0.3.0.dev20251014 → smooth_py-0.3.1}/README.md +0 -0
- {smooth_py-0.3.0.dev20251014 → smooth_py-0.3.1}/src/smooth/mcp/__init__.py +0 -0
- {smooth_py-0.3.0.dev20251014 → 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=(
|
|
@@ -82,7 +125,11 @@ class TaskRequest(BaseModel):
|
|
|
82
125
|
description=("Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials."),
|
|
83
126
|
)
|
|
84
127
|
profile_read_only: bool = Field(
|
|
85
|
-
default=False,
|
|
128
|
+
default=False,
|
|
129
|
+
description=(
|
|
130
|
+
"If true, the profile specified by `profile_id` will be loaded in read-only mode. "
|
|
131
|
+
"Changes made during the task will not be saved back to the profile."
|
|
132
|
+
),
|
|
86
133
|
)
|
|
87
134
|
stealth_mode: bool = Field(default=False, description="Run the browser in stealth mode.")
|
|
88
135
|
proxy_server: str | None = Field(
|
|
@@ -93,6 +140,16 @@ class TaskRequest(BaseModel):
|
|
|
93
140
|
)
|
|
94
141
|
proxy_username: str | None = Field(default=None, description="Proxy server username.")
|
|
95
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.")
|
|
96
153
|
experimental_features: dict[str, Any] | None = Field(
|
|
97
154
|
default=None, description="Experimental features to enable for the task."
|
|
98
155
|
)
|
|
@@ -330,6 +387,10 @@ class BrowserSessionHandle(BaseModel):
|
|
|
330
387
|
return _encode_url(self.browser_session.live_url, interactive=interactive, embed=embed)
|
|
331
388
|
return None
|
|
332
389
|
|
|
390
|
+
def live_id(self):
|
|
391
|
+
"""Returns the live ID for the browser session."""
|
|
392
|
+
return self.browser_session.live_id
|
|
393
|
+
|
|
333
394
|
|
|
334
395
|
class TaskHandle:
|
|
335
396
|
"""A handle to a running task."""
|
|
@@ -393,10 +454,40 @@ class TaskHandle:
|
|
|
393
454
|
task_response = self._client._get_task(self.id())
|
|
394
455
|
self._task_response = task_response
|
|
395
456
|
if task_response.recording_url is not None:
|
|
457
|
+
if not task_response.recording_url:
|
|
458
|
+
raise ApiError(
|
|
459
|
+
status_code=404,
|
|
460
|
+
detail=(
|
|
461
|
+
f"Recording URL not available for task {self.id()}."
|
|
462
|
+
" Set `enable_recording=True` when creating the task to enable it."
|
|
463
|
+
)
|
|
464
|
+
)
|
|
396
465
|
return task_response.recording_url
|
|
397
466
|
time.sleep(1)
|
|
398
467
|
raise TimeoutError(f"Recording URL not available for task {self.id()}.")
|
|
399
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
|
+
|
|
400
491
|
|
|
401
492
|
class SmoothClient(BaseClient):
|
|
402
493
|
"""A synchronous client for the API."""
|
|
@@ -423,20 +514,21 @@ class SmoothClient(BaseClient):
|
|
|
423
514
|
def _submit_task(self, payload: TaskRequest) -> TaskResponse:
|
|
424
515
|
"""Submits a task to be run."""
|
|
425
516
|
try:
|
|
426
|
-
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())
|
|
427
518
|
data = self._handle_response(response)
|
|
428
519
|
return TaskResponse(**data["r"])
|
|
429
520
|
except requests.exceptions.RequestException as e:
|
|
430
521
|
logger.error(f"Request failed: {e}")
|
|
431
522
|
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
432
523
|
|
|
433
|
-
def _get_task(self, task_id: str) -> TaskResponse:
|
|
524
|
+
def _get_task(self, task_id: str, query_params: dict[str, Any] | None = None) -> TaskResponse:
|
|
434
525
|
"""Retrieves the status and result of a task."""
|
|
435
526
|
if not task_id:
|
|
436
527
|
raise ValueError("Task ID cannot be empty.")
|
|
437
528
|
|
|
438
529
|
try:
|
|
439
|
-
|
|
530
|
+
url = f"{self.base_url}/task/{task_id}"
|
|
531
|
+
response = self._session.get(url, params=query_params)
|
|
440
532
|
data = self._handle_response(response)
|
|
441
533
|
return TaskResponse(**data["r"])
|
|
442
534
|
except requests.exceptions.RequestException as e:
|
|
@@ -466,7 +558,7 @@ class SmoothClient(BaseClient):
|
|
|
466
558
|
max_steps: int = 32,
|
|
467
559
|
device: Literal["desktop", "mobile"] = "mobile",
|
|
468
560
|
allowed_urls: list[str] | None = None,
|
|
469
|
-
enable_recording: bool =
|
|
561
|
+
enable_recording: bool = True,
|
|
470
562
|
session_id: str | None = None,
|
|
471
563
|
profile_id: str | None = None,
|
|
472
564
|
profile_read_only: bool = False,
|
|
@@ -474,6 +566,8 @@ class SmoothClient(BaseClient):
|
|
|
474
566
|
proxy_server: str | None = None,
|
|
475
567
|
proxy_username: str | None = None,
|
|
476
568
|
proxy_password: str | None = None,
|
|
569
|
+
certificates: list[Certificate] | None = None,
|
|
570
|
+
use_adblock: bool | None = True,
|
|
477
571
|
experimental_features: dict[str, Any] | None = None,
|
|
478
572
|
) -> TaskHandle:
|
|
479
573
|
"""Runs a task and returns a handle to the task.
|
|
@@ -500,6 +594,11 @@ class SmoothClient(BaseClient):
|
|
|
500
594
|
proxy_server: Proxy server url to route browser traffic through.
|
|
501
595
|
proxy_username: Proxy server username.
|
|
502
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.
|
|
503
602
|
experimental_features: Experimental features to enable for the task.
|
|
504
603
|
|
|
505
604
|
Returns:
|
|
@@ -525,6 +624,8 @@ class SmoothClient(BaseClient):
|
|
|
525
624
|
proxy_server=proxy_server,
|
|
526
625
|
proxy_username=proxy_username,
|
|
527
626
|
proxy_password=proxy_password,
|
|
627
|
+
certificates=_process_certificates(certificates),
|
|
628
|
+
use_adblock=use_adblock,
|
|
528
629
|
experimental_features=experimental_features,
|
|
529
630
|
)
|
|
530
631
|
initial_response = self._submit_task(payload)
|
|
@@ -550,7 +651,7 @@ class SmoothClient(BaseClient):
|
|
|
550
651
|
try:
|
|
551
652
|
response = self._session.post(
|
|
552
653
|
f"{self.base_url}/browser/session",
|
|
553
|
-
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(),
|
|
554
655
|
)
|
|
555
656
|
data = self._handle_response(response)
|
|
556
657
|
return BrowserSessionHandle(browser_session=BrowserSessionResponse(**data["r"]))
|
|
@@ -577,7 +678,7 @@ class SmoothClient(BaseClient):
|
|
|
577
678
|
ApiException: If the API request fails.
|
|
578
679
|
"""
|
|
579
680
|
try:
|
|
580
|
-
response = self._session.get(f"{self.base_url}/browser/
|
|
681
|
+
response = self._session.get(f"{self.base_url}/browser/profile")
|
|
581
682
|
data = self._handle_response(response)
|
|
582
683
|
return BrowserProfilesResponse(**data["r"])
|
|
583
684
|
except requests.exceptions.RequestException as e:
|
|
@@ -592,7 +693,7 @@ class SmoothClient(BaseClient):
|
|
|
592
693
|
def delete_profile(self, profile_id: str):
|
|
593
694
|
"""Delete a browser profile."""
|
|
594
695
|
try:
|
|
595
|
-
response = self._session.delete(f"{self.base_url}/browser/
|
|
696
|
+
response = self._session.delete(f"{self.base_url}/browser/profile/{profile_id}")
|
|
596
697
|
self._handle_response(response)
|
|
597
698
|
except requests.exceptions.RequestException as e:
|
|
598
699
|
logger.error(f"Request failed: {e}")
|
|
@@ -711,11 +812,42 @@ class AsyncTaskHandle:
|
|
|
711
812
|
task_response = await self._client._get_task(self.id())
|
|
712
813
|
self._task_response = task_response
|
|
713
814
|
if task_response.recording_url is not None:
|
|
815
|
+
if not task_response.recording_url:
|
|
816
|
+
raise ApiError(
|
|
817
|
+
status_code=404,
|
|
818
|
+
detail=(
|
|
819
|
+
f"Recording URL not available for task {self.id()}."
|
|
820
|
+
" Set `enable_recording=True` when creating the task to enable it."
|
|
821
|
+
)
|
|
822
|
+
)
|
|
714
823
|
return task_response.recording_url
|
|
715
824
|
await asyncio.sleep(1)
|
|
716
825
|
|
|
717
826
|
raise TimeoutError(f"Recording URL not available for task {self.id()}.")
|
|
718
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
|
+
|
|
719
851
|
|
|
720
852
|
class SmoothAsyncClient(BaseClient):
|
|
721
853
|
"""An asynchronous client for the API."""
|
|
@@ -736,20 +868,21 @@ class SmoothAsyncClient(BaseClient):
|
|
|
736
868
|
async def _submit_task(self, payload: TaskRequest) -> TaskResponse:
|
|
737
869
|
"""Submits a task to be run asynchronously."""
|
|
738
870
|
try:
|
|
739
|
-
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())
|
|
740
872
|
data = self._handle_response(response)
|
|
741
873
|
return TaskResponse(**data["r"])
|
|
742
874
|
except httpx.RequestError as e:
|
|
743
875
|
logger.error(f"Request failed: {e}")
|
|
744
876
|
raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
|
|
745
877
|
|
|
746
|
-
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:
|
|
747
879
|
"""Retrieves the status and result of a task asynchronously."""
|
|
748
880
|
if not task_id:
|
|
749
881
|
raise ValueError("Task ID cannot be empty.")
|
|
750
882
|
|
|
751
883
|
try:
|
|
752
|
-
|
|
884
|
+
url = f"{self.base_url}/task/{task_id}"
|
|
885
|
+
response = await self._client.get(url, params=query_params)
|
|
753
886
|
data = self._handle_response(response)
|
|
754
887
|
return TaskResponse(**data["r"])
|
|
755
888
|
except httpx.RequestError as e:
|
|
@@ -779,7 +912,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
779
912
|
max_steps: int = 32,
|
|
780
913
|
device: Literal["desktop", "mobile"] = "mobile",
|
|
781
914
|
allowed_urls: list[str] | None = None,
|
|
782
|
-
enable_recording: bool =
|
|
915
|
+
enable_recording: bool = True,
|
|
783
916
|
session_id: str | None = None,
|
|
784
917
|
profile_id: str | None = None,
|
|
785
918
|
profile_read_only: bool = False,
|
|
@@ -787,6 +920,8 @@ class SmoothAsyncClient(BaseClient):
|
|
|
787
920
|
proxy_server: str | None = None,
|
|
788
921
|
proxy_username: str | None = None,
|
|
789
922
|
proxy_password: str | None = None,
|
|
923
|
+
certificates: list[Certificate] | None = None,
|
|
924
|
+
use_adblock: bool | None = True,
|
|
790
925
|
experimental_features: dict[str, Any] | None = None,
|
|
791
926
|
) -> AsyncTaskHandle:
|
|
792
927
|
"""Runs a task and returns a handle to the task asynchronously.
|
|
@@ -813,6 +948,11 @@ class SmoothAsyncClient(BaseClient):
|
|
|
813
948
|
proxy_server: Proxy server url to route browser traffic through.
|
|
814
949
|
proxy_username: Proxy server username.
|
|
815
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.
|
|
816
956
|
experimental_features: Experimental features to enable for the task.
|
|
817
957
|
|
|
818
958
|
Returns:
|
|
@@ -838,6 +978,8 @@ class SmoothAsyncClient(BaseClient):
|
|
|
838
978
|
proxy_server=proxy_server,
|
|
839
979
|
proxy_username=proxy_username,
|
|
840
980
|
proxy_password=proxy_password,
|
|
981
|
+
certificates=_process_certificates(certificates),
|
|
982
|
+
use_adblock=use_adblock,
|
|
841
983
|
experimental_features=experimental_features,
|
|
842
984
|
)
|
|
843
985
|
|
|
@@ -863,7 +1005,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
863
1005
|
try:
|
|
864
1006
|
response = await self._client.post(
|
|
865
1007
|
f"{self.base_url}/browser/session",
|
|
866
|
-
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(),
|
|
867
1009
|
)
|
|
868
1010
|
data = self._handle_response(response)
|
|
869
1011
|
return BrowserSessionHandle(browser_session=BrowserSessionResponse(**data["r"]))
|
|
@@ -890,7 +1032,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
890
1032
|
ApiException: If the API request fails.
|
|
891
1033
|
"""
|
|
892
1034
|
try:
|
|
893
|
-
response = await self._client.get(f"{self.base_url}/browser/
|
|
1035
|
+
response = await self._client.get(f"{self.base_url}/browser/profile")
|
|
894
1036
|
data = self._handle_response(response)
|
|
895
1037
|
return BrowserProfilesResponse(**data["r"])
|
|
896
1038
|
except httpx.RequestError as e:
|
|
@@ -905,7 +1047,7 @@ class SmoothAsyncClient(BaseClient):
|
|
|
905
1047
|
async def delete_profile(self, profile_id: str):
|
|
906
1048
|
"""Delete a browser profile."""
|
|
907
1049
|
try:
|
|
908
|
-
response = await self._client.delete(f"{self.base_url}/browser/
|
|
1050
|
+
response = await self._client.delete(f"{self.base_url}/browser/profile/{profile_id}")
|
|
909
1051
|
self._handle_response(response)
|
|
910
1052
|
except httpx.RequestError as e:
|
|
911
1053
|
logger.error(f"Request failed: {e}")
|
|
@@ -976,6 +1118,7 @@ __all__ = [
|
|
|
976
1118
|
"BrowserSessionResponse",
|
|
977
1119
|
"BrowserSessionsResponse",
|
|
978
1120
|
"UploadFileResponse",
|
|
1121
|
+
"Certificate",
|
|
979
1122
|
"ApiError",
|
|
980
1123
|
"TimeoutError",
|
|
981
1124
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|