smooth-py 0.2.8.dev20251008__tar.gz → 0.3.0__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.2.8.dev20251008
3
+ Version: 0.3.0
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.2.8.dev20251008"
3
+ version = "0.3.0"
4
4
  description = ""
5
5
  authors = [
6
6
  {name = "Luca Pinchetti",email = "luca@circlemind.co"}
@@ -13,13 +13,13 @@ from typing import Any, Literal, Type
13
13
  import httpx
14
14
  import requests
15
15
  from deprecated import deprecated
16
- from pydantic import BaseModel, ConfigDict, Field, model_validator
16
+ from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator
17
17
 
18
18
  # Configure logging
19
19
  logger = logging.getLogger("smooth")
20
20
 
21
21
 
22
- BASE_URL = "https://api2.circlemind.co/api/"
22
+ BASE_URL = "https://api.smooth.sh/api/"
23
23
 
24
24
 
25
25
  # --- Utils ---
@@ -100,11 +100,12 @@ class TaskRequest(BaseModel):
100
100
  @model_validator(mode="before")
101
101
  @classmethod
102
102
  def _handle_deprecated_session_id(cls, data: Any) -> Any:
103
- if isinstance(data, dict) and "session_id" in data:
103
+ if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
104
104
  warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
105
105
  data["profile_id"] = data.pop("session_id")
106
106
  return data
107
107
 
108
+ @computed_field(return_type=str | None)
108
109
  @property
109
110
  def session_id(self):
110
111
  """(Deprecated) Returns the session ID."""
@@ -112,12 +113,12 @@ class TaskRequest(BaseModel):
112
113
  return self.profile_id
113
114
 
114
115
  @session_id.setter
115
- def session_id(self, value):
116
+ def session_id(self, value: str | None):
116
117
  """(Deprecated) Sets the session ID."""
117
118
  warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
118
119
  self.profile_id = value
119
120
 
120
- def model_dump(self, **kwargs) -> dict[str, Any]:
121
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
121
122
  """Dump model to dict, including deprecated session_id for retrocompatibility."""
122
123
  data = super().model_dump(**kwargs)
123
124
  # Add deprecated session_id field for retrocompatibility
@@ -137,11 +138,12 @@ class BrowserSessionRequest(BaseModel):
137
138
  @model_validator(mode="before")
138
139
  @classmethod
139
140
  def _handle_deprecated_session_id(cls, data: Any) -> Any:
140
- if isinstance(data, dict) and "session_id" in data:
141
+ if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
141
142
  warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
142
143
  data["profile_id"] = data.pop("session_id")
143
144
  return data
144
145
 
146
+ @computed_field(return_type=str | None)
145
147
  @property
146
148
  def session_id(self):
147
149
  """(Deprecated) Returns the session ID."""
@@ -149,12 +151,12 @@ class BrowserSessionRequest(BaseModel):
149
151
  return self.profile_id
150
152
 
151
153
  @session_id.setter
152
- def session_id(self, value):
154
+ def session_id(self, value: str | None):
153
155
  """(Deprecated) Sets the session ID."""
154
156
  warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
155
157
  self.profile_id = value
156
158
 
157
- def model_dump(self, **kwargs) -> dict[str, Any]:
159
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
158
160
  """Dump model to dict, including deprecated session_id for retrocompatibility."""
159
161
  data = super().model_dump(**kwargs)
160
162
  # Add deprecated session_id field for retrocompatibility
@@ -173,11 +175,12 @@ class BrowserSessionResponse(BaseModel):
173
175
  @model_validator(mode="before")
174
176
  @classmethod
175
177
  def _handle_deprecated_session_id(cls, data: Any) -> Any:
176
- if isinstance(data, dict) and "session_id" in data:
178
+ if isinstance(data, dict) and "session_id" in data and "profile_id" not in data:
177
179
  warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
178
180
  data["profile_id"] = data.pop("session_id")
179
181
  return data
180
182
 
183
+ @computed_field(return_type=str | None)
181
184
  @property
182
185
  def session_id(self):
183
186
  """(Deprecated) Returns the session ID."""
@@ -185,19 +188,11 @@ class BrowserSessionResponse(BaseModel):
185
188
  return self.profile_id
186
189
 
187
190
  @session_id.setter
188
- def session_id(self, value):
191
+ def session_id(self, value: str):
189
192
  """(Deprecated) Sets the session ID."""
190
193
  warnings.warn("'session_id' is deprecated, use 'profile_id' instead", DeprecationWarning, stacklevel=2)
191
194
  self.profile_id = value
192
195
 
193
- def model_dump(self, **kwargs) -> dict[str, Any]:
194
- """Dump model to dict, including deprecated session_id for retrocompatibility."""
195
- data = super().model_dump(**kwargs)
196
- # Add deprecated session_id field for retrocompatibility
197
- if "profile_id" in data:
198
- data["session_id"] = data["profile_id"]
199
- return data
200
-
201
196
 
202
197
  class BrowserProfilesResponse(BaseModel):
203
198
  """Response model for listing browser profiles."""
@@ -207,11 +202,12 @@ class BrowserProfilesResponse(BaseModel):
207
202
  @model_validator(mode="before")
208
203
  @classmethod
209
204
  def _handle_deprecated_session_ids(cls, data: Any) -> Any:
210
- if isinstance(data, dict) and "session_ids" in data:
205
+ if isinstance(data, dict) and "session_ids" in data and "profile_ids" not in data:
211
206
  warnings.warn("'session_ids' is deprecated, use 'profile_ids' instead", DeprecationWarning, stacklevel=2)
212
207
  data["profile_ids"] = data.pop("session_ids")
213
208
  return data
214
209
 
210
+ @computed_field(return_type=list[str])
215
211
  @property
216
212
  def session_ids(self):
217
213
  """(Deprecated) Returns the session IDs."""
@@ -219,12 +215,12 @@ class BrowserProfilesResponse(BaseModel):
219
215
  return self.profile_ids
220
216
 
221
217
  @session_ids.setter
222
- def session_ids(self, value):
218
+ def session_ids(self, value: list[str]):
223
219
  """(Deprecated) Sets the session IDs."""
224
220
  warnings.warn("'session_ids' is deprecated, use 'profile_ids' instead", DeprecationWarning, stacklevel=2)
225
221
  self.profile_ids = value
226
222
 
227
- def model_dump(self, **kwargs) -> dict[str, Any]:
223
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
228
224
  """Dump model to dict, including deprecated session_ids for retrocompatibility."""
229
225
  data = super().model_dump(**kwargs)
230
226
  # Add deprecated session_ids field for retrocompatibility
@@ -351,12 +347,7 @@ class TaskHandle:
351
347
 
352
348
  def stop(self):
353
349
  """Stops the task."""
354
- try:
355
- response = self._client._client.delete(f"{self._client.base_url}/task/{self._id}")
356
- self._handle_response(response)
357
- except requests.exceptions.RequestException as e:
358
- logger.error(f"Request failed: {e}")
359
- raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
350
+ self._client._delete_task(self._id)
360
351
 
361
352
  def result(self, timeout: int | None = None, poll_interval: float = 1) -> TaskResponse:
362
353
  """Waits for the task to complete and returns the result."""
@@ -452,6 +443,18 @@ class SmoothClient(BaseClient):
452
443
  logger.error(f"Request failed: {e}")
453
444
  raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
454
445
 
446
+ def _delete_task(self, task_id: str):
447
+ """Deletes a task."""
448
+ if not task_id:
449
+ raise ValueError("Task ID cannot be empty.")
450
+
451
+ try:
452
+ response = self._session.delete(f"{self.base_url}/task/{task_id}")
453
+ self._handle_response(response)
454
+ except requests.exceptions.RequestException as e:
455
+ logger.error(f"Request failed: {e}")
456
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
457
+
455
458
  def run(
456
459
  self,
457
460
  task: str,
@@ -483,7 +486,7 @@ class SmoothClient(BaseClient):
483
486
  response_model: If provided, the schema describing the desired output structure.
484
487
  url: The starting URL for the task. If not provided, the agent will infer it from the task.
485
488
  metadata: A dictionary containing variables or parameters that will be passed to the agent.
486
- files: A dictionary of file names to their ids. These files will be passed to the agent.
489
+ files: A list of file ids to pass to the agent.
487
490
  agent: The agent to use for the task.
488
491
  max_steps: Maximum number of steps the agent can take (max 64).
489
492
  device: Device type for the task. Default is mobile.
@@ -507,7 +510,7 @@ class SmoothClient(BaseClient):
507
510
  """
508
511
  payload = TaskRequest(
509
512
  task=task,
510
- response_model=response_model.model_json_schema() if issubclass(response_model, BaseModel) else response_model,
513
+ response_model=response_model if isinstance(response_model, dict | None) else response_model.model_json_schema(),
511
514
  url=url,
512
515
  metadata=metadata,
513
516
  files=files,
@@ -660,6 +663,10 @@ class AsyncTaskHandle:
660
663
  """Returns the task ID."""
661
664
  return self._id
662
665
 
666
+ async def stop(self):
667
+ """Stops the task."""
668
+ await self._client._delete_task(self._id)
669
+
663
670
  async def result(self, timeout: int | None = None, poll_interval: float = 1) -> TaskResponse:
664
671
  """Waits for the task to complete and returns the result."""
665
672
  if self._task_response and self._task_response.status not in ["running", "waiting"]:
@@ -679,7 +686,7 @@ class AsyncTaskHandle:
679
686
  await asyncio.sleep(poll_interval)
680
687
  raise TimeoutError(f"Task {self.id()} did not complete within {timeout} seconds.")
681
688
 
682
- async def live_url(self, interactive: bool = True, embed: bool = False, timeout: int | None = None):
689
+ async def live_url(self, interactive: bool = False, embed: bool = False, timeout: int | None = None):
683
690
  """Returns the live URL for the task."""
684
691
  if self._task_response and self._task_response.live_url:
685
692
  return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
@@ -688,13 +695,13 @@ class AsyncTaskHandle:
688
695
  while timeout is None or (time.time() - start_time) < timeout:
689
696
  task_response = await self._client._get_task(self.id())
690
697
  self._task_response = task_response
691
- if task_response.live_url is not None:
698
+ if self._task_response.live_url:
692
699
  return _encode_url(self._task_response.live_url, interactive=interactive, embed=embed)
693
700
  await asyncio.sleep(1)
694
701
 
695
702
  raise TimeoutError(f"Live URL not available for task {self.id()}.")
696
703
 
697
- async def recording_url(self, timeout: int | None = None):
704
+ async def recording_url(self, timeout: int | None = None) -> str:
698
705
  """Returns the recording URL for the task."""
699
706
  if self._task_response and self._task_response.recording_url is not None:
700
707
  return self._task_response.recording_url
@@ -749,6 +756,18 @@ class SmoothAsyncClient(BaseClient):
749
756
  logger.error(f"Request failed: {e}")
750
757
  raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
751
758
 
759
+ async def _delete_task(self, task_id: str):
760
+ """Deletes a task asynchronously."""
761
+ if not task_id:
762
+ raise ValueError("Task ID cannot be empty.")
763
+
764
+ try:
765
+ response = await self._client.delete(f"{self.base_url}/task/{task_id}")
766
+ self._handle_response(response)
767
+ except httpx.RequestError as e:
768
+ logger.error(f"Request failed: {e}")
769
+ raise ApiError(status_code=0, detail=f"Request failed: {str(e)}") from None
770
+
752
771
  async def run(
753
772
  self,
754
773
  task: str,
@@ -780,7 +799,7 @@ class SmoothAsyncClient(BaseClient):
780
799
  response_model: If provided, the schema describing the desired output structure.
781
800
  url: The starting URL for the task. If not provided, the agent will infer it from the task.
782
801
  metadata: A dictionary containing variables or parameters that will be passed to the agent.
783
- files: A dictionary of file names to their url or base64-encoded content to be used by the agent.
802
+ files: A list of file ids to pass to the agent.
784
803
  agent: The agent to use for the task.
785
804
  max_steps: Maximum number of steps the agent can take (max 64).
786
805
  device: Device type for the task. Default is mobile.
@@ -804,7 +823,7 @@ class SmoothAsyncClient(BaseClient):
804
823
  """
805
824
  payload = TaskRequest(
806
825
  task=task,
807
- response_model=response_model.model_json_schema() if issubclass(response_model, BaseModel) else response_model,
826
+ response_model=response_model if isinstance(response_model, dict | None) else response_model.model_json_schema(),
808
827
  url=url,
809
828
  metadata=metadata,
810
829
  files=files,
@@ -831,8 +850,8 @@ class SmoothAsyncClient(BaseClient):
831
850
  """Opens an interactive browser instance asynchronously.
832
851
 
833
852
  Args:
853
+ profile_id: The profile ID to use for the session. If None, a new profile will be created.
834
854
  session_id: (Deprecated, now `profile_id`) The session ID to associate with the browser.
835
- profile_id: The profile ID to associate with the browser.
836
855
  live_view: Whether to enable live view for the session.
837
856
 
838
857
  Returns:
@@ -917,11 +936,12 @@ class SmoothAsyncClient(BaseClient):
917
936
  if name is None:
918
937
  raise ValueError("File name must be provided or the file object must have a 'name' attribute.")
919
938
 
920
- files = {"file": (Path(name).name, file)}
921
939
  if purpose:
922
940
  data = {"file_purpose": purpose}
923
941
  else:
924
942
  data = None
943
+
944
+ files = {"file": (Path(name).name, file)}
925
945
  response = await self._client.post(f"{self.base_url}/file", files=files, data=data)
926
946
  data = self._handle_response(response)
927
947
  return UploadFileResponse(**data["r"])