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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: smooth-py
3
- Version: 0.3.0.post2
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.post2"
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=(
@@ -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(exclude_none=True))
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
- 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)
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(exclude_none=True),
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(exclude_none=True))
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
- 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)
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(exclude_none=True),
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