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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: smooth-py
3
- Version: 0.3.0.dev20251014
3
+ Version: 0.3.1
4
4
  Summary:
5
5
  Author: Luca Pinchetti
6
6
  Author-email: luca@circlemind.co
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "smooth-py"
3
- version = "0.3.0.dev20251014"
3
+ version = "0.3.1"
4
4
  description = ""
5
5
  authors = [
6
6
  {name = "Luca Pinchetti",email = "luca@circlemind.co"}
@@ -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
- # These models define the data structures for API requests and responses.
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="mobile", description="Device type for the task. Default is mobile.")
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, description="If true, the profile specified by `profile_id` will be loaded in read-only mode."
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(exclude_none=True))
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
- response = self._session.get(f"{self.base_url}/task/{task_id}")
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 = False,
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(exclude_none=True),
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/session")
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/session/{profile_id}")
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(exclude_none=True))
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
- response = await self._client.get(f"{self.base_url}/task/{task_id}")
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 = False,
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(exclude_none=True),
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/session")
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/session/{profile_id}")
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
  ]