smooth-py 0.3.0.dev20251014__tar.gz → 0.3.1.dev20251027__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.dev20251027
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.dev20251027"
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,7 @@ 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
+ created_at: int | None = Field(default=None, description="The timestamp when the task was created.")
51
93
 
52
94
 
53
95
  class TaskRequest(BaseModel):
@@ -82,7 +124,11 @@ class TaskRequest(BaseModel):
82
124
  description=("Browser profile ID to use. Each profile maintains its own state, such as cookies and login credentials."),
83
125
  )
84
126
  profile_read_only: bool = Field(
85
- default=False, description="If true, the profile specified by `profile_id` will be loaded in read-only mode."
127
+ default=False,
128
+ description=(
129
+ "If true, the profile specified by `profile_id` will be loaded in read-only mode. "
130
+ "Changes made during the task will not be saved back to the profile."
131
+ ),
86
132
  )
87
133
  stealth_mode: bool = Field(default=False, description="Run the browser in stealth mode.")
88
134
  proxy_server: str | None = Field(
@@ -93,6 +139,15 @@ class TaskRequest(BaseModel):
93
139
  )
94
140
  proxy_username: str | None = Field(default=None, description="Proxy server username.")
95
141
  proxy_password: str | None = Field(default=None, description="Proxy server password.")
142
+ certificates: list[dict[str, Any]] | None = Field(
143
+ default=None,
144
+ description=(
145
+ "List of client certificates to use when accessing secure websites. "
146
+ "Each certificate is a dictionary with the following fields:\n"
147
+ " - `file`: p12 file object to be uploaded (e.g., open('cert.p12', 'rb')).\n"
148
+ " - `password` (optional): Password to decrypt the certificate file."
149
+ ),
150
+ )
96
151
  experimental_features: dict[str, Any] | None = Field(
97
152
  default=None, description="Experimental features to enable for the task."
98
153
  )
@@ -330,6 +385,10 @@ class BrowserSessionHandle(BaseModel):
330
385
  return _encode_url(self.browser_session.live_url, interactive=interactive, embed=embed)
331
386
  return None
332
387
 
388
+ def live_id(self):
389
+ """Returns the live ID for the browser session."""
390
+ return self.browser_session.live_id
391
+
333
392
 
334
393
  class TaskHandle:
335
394
  """A handle to a running task."""
@@ -393,6 +452,14 @@ class TaskHandle:
393
452
  task_response = self._client._get_task(self.id())
394
453
  self._task_response = task_response
395
454
  if task_response.recording_url is not None:
455
+ if not task_response.recording_url:
456
+ raise ApiError(
457
+ status_code=404,
458
+ detail=(
459
+ f"Recording URL not available for task {self.id()}."
460
+ " Set `enable_recording=True` when creating the task to enable it."
461
+ )
462
+ )
396
463
  return task_response.recording_url
397
464
  time.sleep(1)
398
465
  raise TimeoutError(f"Recording URL not available for task {self.id()}.")
@@ -466,7 +533,7 @@ class SmoothClient(BaseClient):
466
533
  max_steps: int = 32,
467
534
  device: Literal["desktop", "mobile"] = "mobile",
468
535
  allowed_urls: list[str] | None = None,
469
- enable_recording: bool = False,
536
+ enable_recording: bool = True,
470
537
  session_id: str | None = None,
471
538
  profile_id: str | None = None,
472
539
  profile_read_only: bool = False,
@@ -474,6 +541,7 @@ class SmoothClient(BaseClient):
474
541
  proxy_server: str | None = None,
475
542
  proxy_username: str | None = None,
476
543
  proxy_password: str | None = None,
544
+ certificates: list[Certificate] | None = None,
477
545
  experimental_features: dict[str, Any] | None = None,
478
546
  ) -> TaskHandle:
479
547
  """Runs a task and returns a handle to the task.
@@ -500,6 +568,10 @@ class SmoothClient(BaseClient):
500
568
  proxy_server: Proxy server url to route browser traffic through.
501
569
  proxy_username: Proxy server username.
502
570
  proxy_password: Proxy server password.
571
+ certificates: List of client certificates to use when accessing secure websites.
572
+ Each certificate is a dictionary with the following fields:
573
+ - `file` (required): p12 file object to be uploaded (e.g., open("cert.p12", "rb")).
574
+ - `password` (optional): Password to decrypt the certificate file, if password-protected.
503
575
  experimental_features: Experimental features to enable for the task.
504
576
 
505
577
  Returns:
@@ -525,6 +597,7 @@ class SmoothClient(BaseClient):
525
597
  proxy_server=proxy_server,
526
598
  proxy_username=proxy_username,
527
599
  proxy_password=proxy_password,
600
+ certificates=_process_certificates(certificates),
528
601
  experimental_features=experimental_features,
529
602
  )
530
603
  initial_response = self._submit_task(payload)
@@ -577,7 +650,7 @@ class SmoothClient(BaseClient):
577
650
  ApiException: If the API request fails.
578
651
  """
579
652
  try:
580
- response = self._session.get(f"{self.base_url}/browser/session")
653
+ response = self._session.get(f"{self.base_url}/browser/profile")
581
654
  data = self._handle_response(response)
582
655
  return BrowserProfilesResponse(**data["r"])
583
656
  except requests.exceptions.RequestException as e:
@@ -592,7 +665,7 @@ class SmoothClient(BaseClient):
592
665
  def delete_profile(self, profile_id: str):
593
666
  """Delete a browser profile."""
594
667
  try:
595
- response = self._session.delete(f"{self.base_url}/browser/session/{profile_id}")
668
+ response = self._session.delete(f"{self.base_url}/browser/profile/{profile_id}")
596
669
  self._handle_response(response)
597
670
  except requests.exceptions.RequestException as e:
598
671
  logger.error(f"Request failed: {e}")
@@ -711,6 +784,14 @@ class AsyncTaskHandle:
711
784
  task_response = await self._client._get_task(self.id())
712
785
  self._task_response = task_response
713
786
  if task_response.recording_url is not None:
787
+ if not task_response.recording_url:
788
+ raise ApiError(
789
+ status_code=404,
790
+ detail=(
791
+ f"Recording URL not available for task {self.id()}."
792
+ " Set `enable_recording=True` when creating the task to enable it."
793
+ )
794
+ )
714
795
  return task_response.recording_url
715
796
  await asyncio.sleep(1)
716
797
 
@@ -779,7 +860,7 @@ class SmoothAsyncClient(BaseClient):
779
860
  max_steps: int = 32,
780
861
  device: Literal["desktop", "mobile"] = "mobile",
781
862
  allowed_urls: list[str] | None = None,
782
- enable_recording: bool = False,
863
+ enable_recording: bool = True,
783
864
  session_id: str | None = None,
784
865
  profile_id: str | None = None,
785
866
  profile_read_only: bool = False,
@@ -787,6 +868,7 @@ class SmoothAsyncClient(BaseClient):
787
868
  proxy_server: str | None = None,
788
869
  proxy_username: str | None = None,
789
870
  proxy_password: str | None = None,
871
+ certificates: list[Certificate] | None = None,
790
872
  experimental_features: dict[str, Any] | None = None,
791
873
  ) -> AsyncTaskHandle:
792
874
  """Runs a task and returns a handle to the task asynchronously.
@@ -813,6 +895,10 @@ class SmoothAsyncClient(BaseClient):
813
895
  proxy_server: Proxy server url to route browser traffic through.
814
896
  proxy_username: Proxy server username.
815
897
  proxy_password: Proxy server password.
898
+ certificates: List of client certificates to use when accessing secure websites.
899
+ Each certificate is a dictionary with the following fields:
900
+ - `file` (required): p12 file object to be uploaded (e.g., open("cert.p12", "rb")).
901
+ - `password` (optional): Password to decrypt the certificate file.
816
902
  experimental_features: Experimental features to enable for the task.
817
903
 
818
904
  Returns:
@@ -838,6 +924,7 @@ class SmoothAsyncClient(BaseClient):
838
924
  proxy_server=proxy_server,
839
925
  proxy_username=proxy_username,
840
926
  proxy_password=proxy_password,
927
+ certificates=_process_certificates(certificates),
841
928
  experimental_features=experimental_features,
842
929
  )
843
930
 
@@ -890,7 +977,7 @@ class SmoothAsyncClient(BaseClient):
890
977
  ApiException: If the API request fails.
891
978
  """
892
979
  try:
893
- response = await self._client.get(f"{self.base_url}/browser/session")
980
+ response = await self._client.get(f"{self.base_url}/browser/profile")
894
981
  data = self._handle_response(response)
895
982
  return BrowserProfilesResponse(**data["r"])
896
983
  except httpx.RequestError as e:
@@ -905,7 +992,7 @@ class SmoothAsyncClient(BaseClient):
905
992
  async def delete_profile(self, profile_id: str):
906
993
  """Delete a browser profile."""
907
994
  try:
908
- response = await self._client.delete(f"{self.base_url}/browser/session/{profile_id}")
995
+ response = await self._client.delete(f"{self.base_url}/browser/profile/{profile_id}")
909
996
  self._handle_response(response)
910
997
  except httpx.RequestError as e:
911
998
  logger.error(f"Request failed: {e}")
@@ -976,6 +1063,7 @@ __all__ = [
976
1063
  "BrowserSessionResponse",
977
1064
  "BrowserSessionsResponse",
978
1065
  "UploadFileResponse",
1066
+ "Certificate",
979
1067
  "ApiError",
980
1068
  "TimeoutError",
981
1069
  ]